ci(windows): optionally sign builder executables with Authenticode #98

Open
mooki wants to merge 5 commits from mooki/Longshot:ci/windows-authenticode into main
Contributor

Why

Signed release checksums do not give the Windows builder a verified publisher. After #99 moved Windows packaging to Linux, this adds optional Linux-native Authenticode without choosing a legal publisher, exporting a private key or promising that SmartScreen warnings disappear.

What changed

HumanBean authored and pushed commit b784ce0e108428560a3aeda515eec3af0b06e46a. He first rebased the four earlier commits onto main fbd420241a, then added his own review fixes. The changes below describe that head, not the earlier implementation.

  • The Linux package job signs three project files after dotnet publish: the compiled LongshotBuilder-Setup.exe, the published LongshotBuilder.exe apphost, and LongshotBuilder.dll, which contains the Builder's own code. Every file must verify before any original is replaced or packaging starts. Third-party binaries remain untouched.

  • ARCHIVE_FILES and check_windows_files.py --authenticode verify four paths in the finished zip:

    Archive path Signed source
    LongshotBuilder.exe setup
    LongshotBuilder/LongshotBuilder-Setup.exe the same setup bytes
    LongshotBuilder/LongshotBuilder.exe Builder apphost
    LongshotBuilder/LongshotBuilder.dll Builder assembly

    The package check receives public certificate/root values, never the key selector or PIN. It precedes artifact upload and release checksums. The setup is also copied unchanged as the small game-folder launch wrapper; the locally compiled game/renderer and separate launcher remain outside this signing scope.

  • AUTHENTICODE_ROOT_PEM, a repository variable, pins the issuing CA's public code-signing root, optionally followed by intermediates, for signer-chain verification through -CAfile. The configured leaf is still pinned separately from AUTHENTICODE_CERTIFICATE_PEM. Unset or blank root: use and log the system bundle, which may lack code-signing-only roots. Set: use the supplied public certificates and log the first certificate's SHA-256 fingerprint. A non-empty malformed value fails instead of falling back. Timestamp-chain and HTTPS trust stay on the system bundle, not this variable.

  • RFC 3161 requests use https://timestamp.digicert.com first, then http://timestamp.sectigo.com if the first request fails. Both are passed as ordered -ts options. If neither succeeds, signing fails; a returned token must still pass signature, chain and message-imprint verification. Missing or invalid timestamps fail before originals are replaced. The Sectigo chain has not yet been confirmed against the runner's bundle.

  • A new secret-free Signing runner step runs authenticode.py runner under the same guard as signing, after publishing and before the PIN-holding step. It checks that both configured PKCS#11 library paths are absolute existing files; failure stops subsequent signing. sign_builder checks the paths again before reading the PIN. This is a presence check, not proof of runner identity or key isolation.

  • Signing still shares the package job with build-time code. HumanBean added dotnet publish --disable-build-servers and documented the limitation, not a separate signing job. The intended follow-up and remaining deployment checks are in Risk and rollback below.

  • The pinned osslsigncode 2.15 download, matching-release guards, named secret forwarding, stdin PIN and redacted tool output remain. Without a signing certificate, the job logs not signed: no certificate configured, downloads no signer and packages unsigned. Retired nightlies still skip signing configuration; checked never skips signing of release files that are rebuilt.

  • The stand-in and its export now also include signed-library-standin.dll and check the four archive paths. That library fixture is the same tiny authored PE under a DLL name, not the real managed Builder assembly. The export keeps the unsigned fixture, signed setup/apphost/library fixtures, positive zip, public certificates and hashes, never keys. It still refuses non-empty output folders.

Flags and settings

Flag What it does Default now Recommendation Helps on Doesn't help on Untested on
AUTHENTICODE_CERTIFICATE_PEM Public signing leaf and intermediates; pins the expected leaf by SHA256 absent, unsigned Leave off until publisher, provider and access controls are agreed Linux-built Windows releases PRs, runtime real provider and Windows trust
AUTHENTICODE_ROOT_PEM Repository variable containing the issuing CA's public code-signing root, optionally intermediates; used as signer-chain -CAfile unset or blank: system bundle and a warning; non-empty malformed: fail, no fallback Pin a reviewed CA root before enabling; compare its published fingerprint code-signing roots absent from the system TLS bundle timestamp/HTTPS trust or Windows trust-store changes selected CA chain on the real runner
AUTHENTICODE_KEY_URI PKCS#11 key selector; credentials and file-key paths are refused absent Use only an approved protected key token/service signing services without PKCS#11 selected provider
AUTHENTICODE_TOKEN_PIN Optional secret passed on stdin, removed from the child environment and not printed absent Only if the provider needs it authenticated key access other authorization methods real token/service
AUTHENTICODE_PKCS11_PROVIDER, AUTHENTICODE_PKCS11_MODULE Absolute library paths checked by the Signing runner step and before PIN use unset Provision reviewed libraries at paths unique to the signing runner; presence alone is insufficient detecting missing libraries proving runner identity or provider authorization live routing and library loading
build-windows.yml input authenticode Selects the guarded signing path and requested Linux runner false Computed from certificate configuration; confirm routing with a canary configured releases PR/manual builds Forgejo evaluation of called-workflow runner selection
AUTHENTICODE_BUILD_REF Checked-out release ref compared with the trigger SHA supplied by workflow; absent refuses signing Keep the matching-commit guard release context runtime live context propagation
authenticode.py plan / runner / sign --setup ... --publish ... --cache ... Plans signing, checks library presence without secrets, or signs/verifies setup, apphost and Builder DLL workflow commands only Keep the runner check before the guarded signing step Linux packaging gameplay or complete key isolation real provider and canary
check_windows_files.py --authenticode --signer-cache ... Verifies four finished-zip paths using the selected signer root and system timestamp trust off unless configured Check every signed package damaged, missing or wrongly signed artifacts proving Windows trust actual release artifact on CI
dotnet publish --disable-build-servers Avoids using persistent build servers for the publish operation enabled in the package job Keep it, but do not treat it as signing isolation build-server lifetime control isolating the key from build-time code live package job
authenticode_standin.py --osslsigncode ... Runs the software-key/local-TSA mechanics test, including a library-named PE explicit, outside automatic suite discovery Testing only command-path and file-format checks public trust or hardware custody real token/service
authenticode_standin.py --out DIR Keeps public fixture EXEs/DLL, positive zip, certificates and hashes; refuses non-empty folders absent; temporary files deleted Opt in for signature-format inspection Windows tools inspecting fixtures real release reputation or working Builder testing current generator/export on Windows

Separate trust choices: AUTHENTICODE_ROOT_PEM affects only the signer's chain in signing verification and the packaged-file check. Unset or whitespace-only uses the system bundle and logs that choice. A supplied public root is written temporarily, its first certificate fingerprint is logged, and it is removed after use. Private-key material, trailing text or malformed certificate data stops the operation; there is no silent fallback from a bad supplied root. The leaf pin remains unchanged.

-TSA-CAfile always uses the system bundle in the production callers. -HTTPS-CAfile also stays on that bundle for HTTPS connections. The ordered timestamp endpoints are DigiCert over HTTPS, then Sectigo over HTTP; the HTTP response still needs a valid signed RFC 3161 token and matching imprint. Either total request failure or later verification failure stops publication. No live fallback success is claimed.

Measurements

Branch state: on main fbd420241afdba1d5ba2936971eb48549edb85ae; head b784ce0e108428560a3aeda515eec3af0b06e46a; whole game-free suite on this head: 516 passed, 0 failed, 15 skipped (main fbd420241afdba1d5ba2936971eb48549edb85ae: 514 passed, 0 failed, 15 skipped). The rebase onto this main and the last commit of the branch are HumanBean's; the suite was run on his head unchanged, on Arch Linux. Main has gained 10 commits since; the branch still merges cleanly.

Historical measurements, not results for b784ce0e

Baseline: baseline-2a2da67f2b
The comparison below predates HumanBean's changes. baseline-2a2da67f2b is the earlier main; earlier-93e3bc3c is the earlier signing implementation. These numbers are retained history, not a measurement of the DLL-inclusive path, pinned signer root, two-server fallback or runner check now in this PR.

Hardware: Linux x86-64, kernel 7.2.15-1-omarchy, Python 3.14.8; clang/LLD 23.1.3, openssl and osslsigncode 2.15
Scene: game-free source/packaging checks
Method: baseline / earlier implementation / baseline / earlier implementation; command wall time and the median of two samples. The stand-in used the earlier implementation's signer with each source's packaging/checksum code and temporary software keys/local TSA. No signing-speed or game-speed conclusion follows.
How to reproduce current source checks: python -I -B tests/test_authenticode.py, python -I -B tests/test_authenticode_standin.py and python -I -B tests/test_windows_on_linux.py. Current results must name b784ce0e; rerunning these now is not a reproduction of the old counts below.

Cells retain run 1 / run 2 counts and wall time, then the median. Stand-in counts are scenarios.

baseline-2a2da67f2b earlier-93e3bc3c
Common named release/package/Linux-workflow checks 36 pass, 0 fail, 0 error / 36 pass, 0 fail, 0 error; 4.123 / 4.065 s; median 4.094 s 36 pass, 0 fail, 0 error / 36 pass, 0 fail, 0 error; 4.104 / 4.038 s; median 4.071 s
Earlier optional workflow contract 0 pass, 0 fail, 1 error / 0 pass, 0 fail, 1 error; 0.124 / 0.057 s; median 0.090 s 1 pass, 0 fail, 0 error / 1 pass, 0 fail, 0 error; 0.054 / 0.063 s; median 0.059 s
Earlier software-key/TSA stand-in 1 pass, 0 fail, 0 error / 1 pass, 0 fail, 0 error; 1.323 / 1.465 s; median 1.394 s 1 pass, 0 fail, 0 error / 1 pass, 0 fail, 0 error; 1.300 / 1.212 s; median 1.256 s

The baseline lacked the optional workflow step, giving the expected missing-feature lookup error, not a Windows failure. The earlier signing file passed its then-14 cases twice. The old fixture's scope did not include the Builder DLL; its log lines and sizes are not reused as results for today's four-path package check. Historical export-only checks at e2a27e94 likewise predate the DLL addition and do not establish the current export's execution result.

Testing

Tested: earlier results are retained only at their tested commits. The source/export update at e2a27e94 passed 14 signing cases, 6 workflow cases and 8 pure-Python export cases; the export comparison ran before/after/before/after against the earlier stand-in. The real software-key/loopback-TSA measurements were at 93e3bc3c. Those checks predate HumanBean's DLL, trust-root, timestamp-list and runner-check changes and are not a pass for b784ce0e. Use the branch-state block for the separately recorded current-head suite result.

HumanBean said he parsed the edited Python files but ran no tests, signer, build or workflow for his commit. The updated tests now describe the three signed inputs, four archive paths, distinct signer/TSA trust, timestamp order, runner guard and library fixture. Reading those tests is not an execution result. No signed release or new export bundle is claimed here.

Reported separately, not a test of this branch: d4nkn3ss's Windows 11 follow-up used main 316f9319 and PowerShell signing. Its unsigned and self-signed setup copies scanned clean on demand and returned S_OK through IAttachmentExecute::Save; Windows reported the self-signed chain as untrusted. His earlier download history does not establish why verdicts changed or predict a new release's verdict. A self-signed fixture remains mechanics evidence, not public publisher trust or a detection-rate improvement.

Not tested: real PKCS#11 provider/key access, live Forgejo runner selection and the canary, live DigiCert/Sectigo requests, the Sectigo timestamp chain against the runner's bundle, Windows acceptance of this branch's setup/apphost/managed DLL, or browser/Defender/SmartScreen outcomes for its release files. The library stand-in is an authored PE with a DLL filename, not a working managed Builder library. No game-speed claim is made.

Needs other testers:

  • Linux signing runner with an approved provider and Windows 10/11: confirm the runner canary and independent key-access controls, selected code-signing root, both timestamp routes including DigiCert-blocked fallback, all four packaged signatures/checksums and fail-closed cases; inspect native Windows trust and actual release downloads separately from the tiny fixtures (T-RSMT)

Screenshots

Nothing on screen. Windows prompts have not been observed.

Risk and rollback

Open before signing is enabled:

  • Sectigo fallback trust is unconfirmed. Its timestamp token must chain to the runner's system bundle, independently of AUTHENTICODE_ROOT_PEM. The review could not confirm whether the token supplies the USERTrust cross-signature needed for Sectigo Public Time Stamping Root R46. The fallback may therefore fail every verification even when it answers. Confirm it with a canary in which DigiCert is unavailable, or review the timestamp trust configuration before relying on it. It still fails closed; this description update does not fix or confirm the chain.
  • The runner check proves file presence only. A provider/module path that also exists on an ordinary Ubuntu runner would pass there. Use paths unique to the signing runner and confirm actual routing with the canary; a success message alone is not an identity check. The check can stop PIN use when libraries are absent, but cannot prevent the runner selected for the job from receiving the job's secrets. Machine-bound token/service authorization and independent runner admission remain required. This limitation is not fixed here.
  • Signing and building still share one job. NuGet/MSBuild build-time code, package installation and later Wine share the package runner with signing. --disable-build-servers is a mitigation, not key isolation. The documented follow-up is a build job that uploads unsigned setup/publish files and their SHA-256 values, a separate guarded signing job on linux-signing that checks those hashes, signs, verifies and uploads, then a packaging job that uses only the signed files. This is not implemented. The two extra artifact transfers and Forgejo's skipped-job handling in needs require a real workflow test.

The Signing runner step has no secrets and must run under the same guard immediately before signing. A harmless canary must confirm the job is actually on linux-signing and prints signing runner: the PKCS#11 provider and module are present. Missing libraries must produce not on the signing runner and stop before PIN use. Keep real credentials disabled until the routing and authorization checks are settled.

Any configured signing, root, timestamp, leaf or packaged-file verification failure stops publication. Reverting the signing commits removes the optional path. Removing the signing-certificate secret returns to explicitly unsigned output, which must not be done silently after promising signed releases. Linux verification is not Windows trust or SmartScreen reputation.

Reviewer notes

Commit b784ce0e108428560a3aeda515eec3af0b06e46a is HumanBean's own commit on top of the four commits he rebased onto fbd420241a. His re-review says the code can merge with signing off; it does not certify deployment or live-provider behaviour. This update changes only the description.

Read trust_anchor, signing_runner, PUBLISH_FILES, ARCHIVE_FILES, sign_files and the two production callers that keep timestamp/HTTPS trust on the system bundle. The Signing runner step, the DLL-inclusive stand-in/export and the same-job follow-up are now described above. The two remaining should-change points are explicitly open in Risk, not presented as fixed.

Maintainers still choose the legal publisher, service/token, root certificate, custody and approval policy, and eventual signing-required channels. A proprietary service API is not automatically supported by the PKCS#11 route. The separate launcher and setup metadata remain parts of issue 46. Do not import fixture roots or disable Defender; T-RSMT remains the live deployment/consumer testing scope.

Issues

See #46

Checklist

For everyone

  • One topic.
  • The title is a Conventional Commit (type(scope): description).
  • Rebased on current main.
  • python -I -B tests/run_all.py run and passing (any failure that also fails on main is named above).
  • Tested in the game, or what wasn't is named under Testing.
  • Testing filled in: what was tested, what wasn't, and what needs other testers (with test requests). T-RSMT covers the revised Linux-signing and Windows-consumer scope.
  • Measured: main and this PR at named commits, on the same PC, scene and settings, at least 2 interleaved runs each, the median reported. Named checks and a labelled software-key/TSA stand-in; no game-speed claim.
  • No game files, dumps, build output or generated files.
  • third_party/ untouched (a new folder with a PROVENANCE.md instead).
  • The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled.

If applicable

  • AI assistance recorded with Assisted-by: / Co-Authored-By: trailers.
  • Flags noted, and the switch reference updated (docs/development.md, the README). Release-only settings documented in release docs; no runtime setting.
  • A check under tests/ that reads changed source updated in this PR, not weakened.
  • Docs updated.

New on this forge; not opened on GitHub.

Assisted-by: GPT-6 Astra
Co-Authored-By: GPT-6 Astra

## Why Signed release checksums do not give the Windows builder a verified publisher. After #99 moved Windows packaging to Linux, this adds optional Linux-native Authenticode without choosing a legal publisher, exporting a private key or promising that SmartScreen warnings disappear. ## What changed **HumanBean authored and pushed commit `b784ce0e108428560a3aeda515eec3af0b06e46a`.** He first rebased the four earlier commits onto main `fbd420241a`, then added his own review fixes. The changes below describe that head, not the earlier implementation. - The Linux package job signs three project files after `dotnet publish`: the compiled `LongshotBuilder-Setup.exe`, the published `LongshotBuilder.exe` apphost, and `LongshotBuilder.dll`, which contains the Builder's own code. Every file must verify before any original is replaced or packaging starts. Third-party binaries remain untouched. - `ARCHIVE_FILES` and `check_windows_files.py --authenticode` verify four paths in the finished zip: | Archive path | Signed source | |---|---| | `LongshotBuilder.exe` | setup | | `LongshotBuilder/LongshotBuilder-Setup.exe` | the same setup bytes | | `LongshotBuilder/LongshotBuilder.exe` | Builder apphost | | `LongshotBuilder/LongshotBuilder.dll` | Builder assembly | The package check receives public certificate/root values, never the key selector or PIN. It precedes artifact upload and release checksums. The setup is also copied unchanged as the small game-folder launch wrapper; the locally compiled game/renderer and separate launcher remain outside this signing scope. - `AUTHENTICODE_ROOT_PEM`, a repository **variable**, pins the issuing CA's public code-signing root, optionally followed by intermediates, for signer-chain verification through `-CAfile`. The configured leaf is still pinned separately from `AUTHENTICODE_CERTIFICATE_PEM`. Unset or blank root: use and log the system bundle, which may lack code-signing-only roots. Set: use the supplied public certificates and log the first certificate's SHA-256 fingerprint. A non-empty malformed value fails instead of falling back. Timestamp-chain and HTTPS trust stay on the system bundle, not this variable. - RFC 3161 requests use `https://timestamp.digicert.com` first, then `http://timestamp.sectigo.com` if the first request fails. Both are passed as ordered `-ts` options. If neither succeeds, signing fails; a returned token must still pass signature, chain and message-imprint verification. Missing or invalid timestamps fail before originals are replaced. The Sectigo chain has not yet been confirmed against the runner's bundle. - A new secret-free **Signing runner** step runs `authenticode.py runner` under the same guard as signing, after publishing and before the PIN-holding step. It checks that both configured PKCS#11 library paths are absolute existing files; failure stops subsequent signing. `sign_builder` checks the paths again before reading the PIN. This is a presence check, not proof of runner identity or key isolation. - Signing still shares the package job with build-time code. HumanBean added `dotnet publish --disable-build-servers` and documented the limitation, not a separate signing job. The intended follow-up and remaining deployment checks are in Risk and rollback below. - The pinned osslsigncode 2.15 download, matching-release guards, named secret forwarding, stdin PIN and redacted tool output remain. Without a signing certificate, the job logs `not signed: no certificate configured`, downloads no signer and packages unsigned. Retired nightlies still skip signing configuration; `checked` never skips signing of release files that are rebuilt. - The stand-in and its export now also include `signed-library-standin.dll` and check the four archive paths. That library fixture is the same tiny authored PE under a DLL name, not the real managed Builder assembly. The export keeps the unsigned fixture, signed setup/apphost/library fixtures, positive zip, public certificates and hashes, never keys. It still refuses non-empty output folders. ## Flags and settings | Flag | What it does | Default now | Recommendation | Helps on | Doesn't help on | Untested on | |---|---|---|---|---|---|---| | `AUTHENTICODE_CERTIFICATE_PEM` | Public signing leaf and intermediates; pins the expected leaf by SHA256 | absent, unsigned | Leave off until publisher, provider and access controls are agreed | Linux-built Windows releases | PRs, runtime | real provider and Windows trust | | `AUTHENTICODE_ROOT_PEM` | Repository variable containing the issuing CA's public code-signing root, optionally intermediates; used as signer-chain `-CAfile` | unset or blank: system bundle and a warning; non-empty malformed: fail, no fallback | Pin a reviewed CA root before enabling; compare its published fingerprint | code-signing roots absent from the system TLS bundle | timestamp/HTTPS trust or Windows trust-store changes | selected CA chain on the real runner | | `AUTHENTICODE_KEY_URI` | PKCS#11 key selector; credentials and file-key paths are refused | absent | Use only an approved protected key | token/service signing | services without PKCS#11 | selected provider | | `AUTHENTICODE_TOKEN_PIN` | Optional secret passed on stdin, removed from the child environment and not printed | absent | Only if the provider needs it | authenticated key access | other authorization methods | real token/service | | `AUTHENTICODE_PKCS11_PROVIDER`, `AUTHENTICODE_PKCS11_MODULE` | Absolute library paths checked by the Signing runner step and before PIN use | unset | Provision reviewed libraries at paths unique to the signing runner; presence alone is insufficient | detecting missing libraries | proving runner identity or provider authorization | live routing and library loading | | `build-windows.yml` input `authenticode` | Selects the guarded signing path and requested Linux runner | false | Computed from certificate configuration; confirm routing with a canary | configured releases | PR/manual builds | Forgejo evaluation of called-workflow runner selection | | `AUTHENTICODE_BUILD_REF` | Checked-out release ref compared with the trigger SHA | supplied by workflow; absent refuses signing | Keep the matching-commit guard | release context | runtime | live context propagation | | `authenticode.py plan` / `runner` / `sign --setup ... --publish ... --cache ...` | Plans signing, checks library presence without secrets, or signs/verifies setup, apphost and Builder DLL | workflow commands only | Keep the runner check before the guarded signing step | Linux packaging | gameplay or complete key isolation | real provider and canary | | `check_windows_files.py --authenticode --signer-cache ...` | Verifies four finished-zip paths using the selected signer root and system timestamp trust | off unless configured | Check every signed package | damaged, missing or wrongly signed artifacts | proving Windows trust | actual release artifact on CI | | `dotnet publish --disable-build-servers` | Avoids using persistent build servers for the publish operation | enabled in the package job | Keep it, but do not treat it as signing isolation | build-server lifetime control | isolating the key from build-time code | live package job | | `authenticode_standin.py --osslsigncode ...` | Runs the software-key/local-TSA mechanics test, including a library-named PE | explicit, outside automatic suite discovery | Testing only | command-path and file-format checks | public trust or hardware custody | real token/service | | `authenticode_standin.py --out DIR` | Keeps public fixture EXEs/DLL, positive zip, certificates and hashes; refuses non-empty folders | absent; temporary files deleted | Opt in for signature-format inspection | Windows tools inspecting fixtures | real release reputation or working Builder testing | current generator/export on Windows | **Separate trust choices:** `AUTHENTICODE_ROOT_PEM` affects only the signer's chain in signing verification and the packaged-file check. Unset or whitespace-only uses the system bundle and logs that choice. A supplied public root is written temporarily, its first certificate fingerprint is logged, and it is removed after use. Private-key material, trailing text or malformed certificate data stops the operation; there is no silent fallback from a bad supplied root. The leaf pin remains unchanged. `-TSA-CAfile` always uses the system bundle in the production callers. `-HTTPS-CAfile` also stays on that bundle for HTTPS connections. The ordered timestamp endpoints are DigiCert over HTTPS, then Sectigo over HTTP; the HTTP response still needs a valid signed RFC 3161 token and matching imprint. Either total request failure or later verification failure stops publication. No live fallback success is claimed. ## Measurements <!-- branch-state --> Branch state: on main `fbd420241afdba1d5ba2936971eb48549edb85ae`; head `b784ce0e108428560a3aeda515eec3af0b06e46a`; whole game-free suite on this head: `516 passed, 0 failed, 15 skipped` (main `fbd420241afdba1d5ba2936971eb48549edb85ae`: `514 passed, 0 failed, 15 skipped`). The rebase onto this main and the last commit of the branch are HumanBean's; the suite was run on his head unchanged, on Arch Linux. Main has gained 10 commits since; the branch still merges cleanly. <!-- /branch-state --> ### Historical measurements, not results for b784ce0e Baseline: baseline-2a2da67f2b The comparison below predates HumanBean's changes. `baseline-2a2da67f2b` is the earlier main; `earlier-93e3bc3c` is the earlier signing implementation. These numbers are retained history, not a measurement of the DLL-inclusive path, pinned signer root, two-server fallback or runner check now in this PR. Hardware: Linux x86-64, kernel 7.2.15-1-omarchy, Python 3.14.8; clang/LLD 23.1.3, openssl and osslsigncode 2.15 Scene: game-free source/packaging checks Method: baseline / earlier implementation / baseline / earlier implementation; command wall time and the median of two samples. The stand-in used the earlier implementation's signer with each source's packaging/checksum code and temporary software keys/local TSA. No signing-speed or game-speed conclusion follows. How to reproduce current source checks: `python -I -B tests/test_authenticode.py`, `python -I -B tests/test_authenticode_standin.py` and `python -I -B tests/test_windows_on_linux.py`. Current results must name `b784ce0e`; rerunning these now is not a reproduction of the old counts below. Cells retain run 1 / run 2 counts and wall time, then the median. Stand-in counts are scenarios. | | baseline-2a2da67f2b | earlier-93e3bc3c | |---|---|---| | Common named release/package/Linux-workflow checks | 36 pass, 0 fail, 0 error / 36 pass, 0 fail, 0 error; 4.123 / 4.065 s; median 4.094 s | 36 pass, 0 fail, 0 error / 36 pass, 0 fail, 0 error; 4.104 / 4.038 s; median 4.071 s | | Earlier optional workflow contract | 0 pass, 0 fail, 1 error / 0 pass, 0 fail, 1 error; 0.124 / 0.057 s; median 0.090 s | 1 pass, 0 fail, 0 error / 1 pass, 0 fail, 0 error; 0.054 / 0.063 s; median 0.059 s | | Earlier software-key/TSA stand-in | 1 pass, 0 fail, 0 error / 1 pass, 0 fail, 0 error; 1.323 / 1.465 s; median 1.394 s | 1 pass, 0 fail, 0 error / 1 pass, 0 fail, 0 error; 1.300 / 1.212 s; median 1.256 s | The baseline lacked the optional workflow step, giving the expected missing-feature lookup error, not a Windows failure. The earlier signing file passed its then-14 cases twice. The old fixture's scope did not include the Builder DLL; its log lines and sizes are not reused as results for today's four-path package check. Historical export-only checks at `e2a27e94` likewise predate the DLL addition and do not establish the current export's execution result. ## Testing **Tested:** earlier results are retained only at their tested commits. The source/export update at `e2a27e94` passed 14 signing cases, 6 workflow cases and 8 pure-Python export cases; the export comparison ran before/after/before/after against the earlier stand-in. The real software-key/loopback-TSA measurements were at `93e3bc3c`. Those checks predate HumanBean's DLL, trust-root, timestamp-list and runner-check changes and are not a pass for `b784ce0e`. Use the branch-state block for the separately recorded current-head suite result. HumanBean said he parsed the edited Python files but ran no tests, signer, build or workflow for his commit. The updated tests now describe the three signed inputs, four archive paths, distinct signer/TSA trust, timestamp order, runner guard and library fixture. Reading those tests is not an execution result. No signed release or new export bundle is claimed here. **Reported separately, not a test of this branch:** [d4nkn3ss's Windows 11 follow-up](https://git.magicbean.dev/MagicBeanDev/Longshot/issues/46#issuecomment-12716) used main `316f9319` and PowerShell signing. Its unsigned and self-signed setup copies scanned clean on demand and returned S_OK through `IAttachmentExecute::Save`; Windows reported the self-signed chain as untrusted. His [earlier download history](https://git.magicbean.dev/MagicBeanDev/Longshot/issues/46#issuecomment-12689) does not establish why verdicts changed or predict a new release's verdict. A self-signed fixture remains mechanics evidence, not public publisher trust or a detection-rate improvement. **Not tested:** real PKCS#11 provider/key access, live Forgejo runner selection and the canary, live DigiCert/Sectigo requests, the Sectigo timestamp chain against the runner's bundle, Windows acceptance of this branch's setup/apphost/managed DLL, or browser/Defender/SmartScreen outcomes for its release files. The library stand-in is an authored PE with a DLL filename, not a working managed Builder library. No game-speed claim is made. **Needs other testers:** - Linux signing runner with an approved provider and Windows 10/11: confirm the runner canary and independent key-access controls, selected code-signing root, both timestamp routes including DigiCert-blocked fallback, all four packaged signatures/checksums and fail-closed cases; inspect native Windows trust and actual release downloads separately from the tiny fixtures (T-RSMT) ## Screenshots Nothing on screen. Windows prompts have not been observed. ## Risk and rollback **Open before signing is enabled:** - **Sectigo fallback trust is unconfirmed.** Its timestamp token must chain to the runner's system bundle, independently of `AUTHENTICODE_ROOT_PEM`. The review could not confirm whether the token supplies the USERTrust cross-signature needed for Sectigo Public Time Stamping Root R46. The fallback may therefore fail every verification even when it answers. Confirm it with a canary in which DigiCert is unavailable, or review the timestamp trust configuration before relying on it. It still fails closed; this description update does not fix or confirm the chain. - **The runner check proves file presence only.** A provider/module path that also exists on an ordinary Ubuntu runner would pass there. Use paths unique to the signing runner and confirm actual routing with the canary; a success message alone is not an identity check. The check can stop PIN use when libraries are absent, but cannot prevent the runner selected for the job from receiving the job's secrets. Machine-bound token/service authorization and independent runner admission remain required. This limitation is not fixed here. - **Signing and building still share one job.** NuGet/MSBuild build-time code, package installation and later Wine share the package runner with signing. `--disable-build-servers` is a mitigation, not key isolation. The documented follow-up is a build job that uploads unsigned setup/publish files and their SHA-256 values, a separate guarded signing job on `linux-signing` that checks those hashes, signs, verifies and uploads, then a packaging job that uses only the signed files. This is not implemented. The two extra artifact transfers and Forgejo's skipped-job handling in `needs` require a real workflow test. The **Signing runner** step has no secrets and must run under the same guard immediately before signing. A harmless canary must confirm the job is actually on `linux-signing` and prints `signing runner: the PKCS#11 provider and module are present`. Missing libraries must produce `not on the signing runner` and stop before PIN use. Keep real credentials disabled until the routing and authorization checks are settled. Any configured signing, root, timestamp, leaf or packaged-file verification failure stops publication. Reverting the signing commits removes the optional path. Removing the signing-certificate secret returns to explicitly unsigned output, which must not be done silently after promising signed releases. Linux verification is not Windows trust or SmartScreen reputation. ## Reviewer notes Commit `b784ce0e108428560a3aeda515eec3af0b06e46a` is HumanBean's own commit on top of the four commits he rebased onto `fbd420241a`. His re-review says the code can merge with signing off; it does not certify deployment or live-provider behaviour. This update changes only the description. Read `trust_anchor`, `signing_runner`, `PUBLISH_FILES`, `ARCHIVE_FILES`, `sign_files` and the two production callers that keep timestamp/HTTPS trust on the system bundle. The Signing runner step, the DLL-inclusive stand-in/export and the same-job follow-up are now described above. The two remaining should-change points are explicitly open in Risk, not presented as fixed. Maintainers still choose the legal publisher, service/token, root certificate, custody and approval policy, and eventual signing-required channels. A proprietary service API is not automatically supported by the PKCS#11 route. The separate launcher and setup metadata remain parts of issue 46. Do not import fixture roots or disable Defender; T-RSMT remains the live deployment/consumer testing scope. ## Issues See #46 ## Checklist ### For everyone - [x] One topic. - [x] The title is a Conventional Commit (`type(scope): description`). - [x] Rebased on current `main`. - [x] `python -I -B tests/run_all.py` run and passing (any failure that also fails on `main` is named above). - [x] Tested in the game, or what wasn't is named under Testing. - [x] Testing filled in: what was tested, what wasn't, and what needs other testers (with test requests). T-RSMT covers the revised Linux-signing and Windows-consumer scope. - [x] Measured: main and this PR at named commits, on the same PC, scene and settings, at least 2 interleaved runs each, the median reported. Named checks and a labelled software-key/TSA stand-in; no game-speed claim. - [x] No game files, dumps, build output or generated files. - [x] `third_party/` untouched (a new folder with a `PROVENANCE.md` instead). - [x] The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled. ### If applicable - [x] AI assistance recorded with `Assisted-by:` / `Co-Authored-By:` trailers. - [x] Flags noted, and the switch reference updated (docs/development.md, the README). Release-only settings documented in release docs; no runtime setting. - [x] A check under `tests/` that reads changed source updated in this PR, not weakened. - [x] Docs updated. --- New on this forge; not opened on GitHub. Assisted-by: GPT-6 Astra Co-Authored-By: GPT-6 Astra
Author
Contributor

Adapted to #99's Linux packaging: pinned osslsigncode now signs through a PKCS#11 token/service interface, and the Windows-file checker also verifies the completed zip before upload and checksums. The real command-path stand-in passes with temporary software keys and a loopback RFC 3161 authority, 14 signing unit tests pass twice, and T-RSMT now needs Linux provider/key-access checks plus Windows trust checks; neither the stand-in nor Linux verification proves public publisher trust. The test request for the reworked flow is open (T-RSMT; it replaces the earlier one). Rebased onto main b9cdc4326; whole suite on the new head eb510a3cb: 512 passed, 0 failed, 19 skipped. Ready for review.

Adapted to #99's Linux packaging: pinned osslsigncode now signs through a PKCS#11 token/service interface, and the Windows-file checker also verifies the completed zip before upload and checksums. The real command-path stand-in passes with temporary software keys and a loopback RFC 3161 authority, 14 signing unit tests pass twice, and T-RSMT now needs Linux provider/key-access checks plus Windows trust checks; neither the stand-in nor Linux verification proves public publisher trust. The test request for the reworked flow is open (T-RSMT; it replaces the earlier one). Rebased onto main b9cdc4326; whole suite on the new head eb510a3cb: 512 passed, 0 failed, 19 skipped. Ready for review.
mooki force-pushed ci/windows-authenticode from 57698f8b0f
Some checks failed
Linux AppImage / appimage (pull_request) Blocked by required conditions
build-windows.yml / ci(windows): optionally sign builder executables with Authenticode (pull_request) Failing after 0s
PR description / Description follows the template (pull_request_target) Successful in 3s
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been cancelled
CI / Python source checks (windows-2022, part 1/2) (pull_request) Has been cancelled
CI / Python source checks (windows-2022, part 2/2) (pull_request) Has been cancelled
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
Third-party parts / As their notes say (pull_request) Has been cancelled
Third-party parts / The same as upstream (pull_request) Has been cancelled
to eb510a3cba
Some checks failed
build-windows.yml / ci(windows): sign Linux-built artifacts through a pinned PKCS11 client (pull_request) Failing after 0s
PR description / Description follows the template (pull_request_target) Successful in 3s
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been cancelled
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
Third-party parts / As their notes say (pull_request) Has been cancelled
Third-party parts / The same as upstream (pull_request) Has been cancelled
2026-11-13 04:10:00 +00:00
Compare
mooki changed title from WIP: ci(windows): optionally sign builder executables with Authenticode to ci(windows): optionally sign builder executables with Authenticode 2026-11-13 04:10:01 +00:00
Contributor

An offer of Windows-side checks for this, whenever they're useful. I have a Windows 11 PC with Defender fully on (real-time and cloud protection, tamper protection) and its detection history for Hookshot's releases: #46 (comment).

What I can run here and post back, each against an unsigned build of the same commit:

  • A signed stand-in build: Windows' own view of the signature. Get-AuthenticodeSignature / signtool verify /pa /v on the setup, the apphost and the Builder exe, the timestamp, and whether the chain is accepted. That checks the osslsigncode path's output against Windows rather than against itself.
  • The download path players take: download through the browser (Mark of the Web) and start each, signed and unsigned. What Defender's history and SmartScreen say, read back the same way as in the #46 comment.
  • Whatever release goes out next: the same reading on its files.

No decisions needed from anyone for any of this, and nothing gets signed here. Just point me at the artifacts when there's a build to try.

An offer of Windows-side checks for this, whenever they're useful. I have a Windows 11 PC with Defender fully on (real-time and cloud protection, tamper protection) and its detection history for Hookshot's releases: https://git.magicbean.dev/MagicBeanDev/Longshot/issues/46#issuecomment-12689. What I can run here and post back, each against an unsigned build of the same commit: - **A signed stand-in build:** Windows' own view of the signature. `Get-AuthenticodeSignature` / `signtool verify /pa /v` on the setup, the apphost and the Builder exe, the timestamp, and whether the chain is accepted. That checks the osslsigncode path's output against Windows rather than against itself. - **The download path players take:** download through the browser (Mark of the Web) and start each, signed and unsigned. What Defender's history and SmartScreen say, read back the same way as in the #46 comment. - **Whatever release goes out next:** the same reading on its files. No decisions needed from anyone for any of this, and nothing gets signed here. Just point me at the artifacts when there's a build to try.
Author
Contributor

Re-fitted signing onto main's current command-line Builder and release flow: checked-release inputs, quick jobs, artifact retries and the retired-nightly skip are preserved, while every package that proceeds is signed and verified before upload. A separate commit adds --out DIR for tiny public signature fixtures with no keys; 14 signing, 6 workflow and 8 export checks pass; no real fixture was rebuilt for this update. Head c2e7d2287 on main dd02c6cca; the whole game-free suite on it: 507 passed, 2 failed, 15 skipped.

Re-fitted signing onto main's current command-line Builder and release flow: checked-release inputs, quick jobs, artifact retries and the retired-nightly skip are preserved, while every package that proceeds is signed and verified before upload. A separate commit adds `--out DIR` for tiny public signature fixtures with no keys; 14 signing, 6 workflow and 8 export checks pass; no real fixture was rebuilt for this update. Head `c2e7d2287` on main `dd02c6cca`; the whole game-free suite on it: 507 passed, 2 failed, 15 skipped.
mooki force-pushed ci/windows-authenticode from eb510a3cba
Some checks failed
build-windows.yml / ci(windows): sign Linux-built artifacts through a pinned PKCS11 client (pull_request) Failing after 0s
PR description / Description follows the template (pull_request_target) Successful in 3s
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been cancelled
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
Third-party parts / As their notes say (pull_request) Has been cancelled
Third-party parts / The same as upstream (pull_request) Has been cancelled
to c2e7d22877
Some checks failed
build-windows.yml / test(release): export public Authenticode stand-ins for Windows checks (pull_request) Failing after 0s
PR description / Description follows the template (pull_request_target) Successful in 3s
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been cancelled
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
Third-party parts / As their notes say (pull_request) Has been cancelled
Third-party parts / The same as upstream (pull_request) Has been cancelled
Website / Website on Cloudflare (pull_request) Has been cancelled
2026-11-13 15:08:51 +00:00
Compare
Author
Contributor

Thanks: your download-time checks and the untrusted self-signed result reinforce the distinction here. The branch now has an export for your first check: Windows' own reading of the signature and timestamp, not a Defender/reputation comparison.

From this PR's checkout, on a PC with Python 3.12+, OpenSSL 3, clang, lld-link and osslsigncode 2.15 for that OS (Linux/WSL is one route):

python -I -B tests/authenticode_standin.py --osslsigncode /path/to/osslsigncode --out signing-standin

The new/empty output folder gets:

  • unsigned-standin.exe, signed-setup-standin.exe, signed-apphost-standin.exe;
  • standin-package.zip, with the three release-style EXE paths (two are copies of the setup);
  • public signer.pem and timestamp.pem, plus SHA256SUMS, never a private key.

These are tiny authored fixtures, not working Builder binaries. Please record the hashes, Get-AuthenticodeSignature / signtool verify /pa /v results and timestamp details; untrusted roots are expected. Do not import these certificates as trusted roots or disable Defender.

No CI build is needed to produce those fixtures yourself. Your browser-download and next-release checks need real release files; those builds remain the maintainers' to start, and these fixtures make no promise about their verdicts.

Thanks: your download-time checks and the untrusted self-signed result reinforce the distinction here. The branch now has an export for your **first check: Windows' own reading of the signature and timestamp**, not a Defender/reputation comparison. From this PR's checkout, on a PC with Python 3.12+, OpenSSL 3, clang, lld-link and osslsigncode 2.15 for that OS (Linux/WSL is one route): ```sh python -I -B tests/authenticode_standin.py --osslsigncode /path/to/osslsigncode --out signing-standin ``` The new/empty output folder gets: - `unsigned-standin.exe`, `signed-setup-standin.exe`, `signed-apphost-standin.exe`; - `standin-package.zip`, with the three release-style EXE paths (two are copies of the setup); - public `signer.pem` and `timestamp.pem`, plus `SHA256SUMS`, never a private key. These are **tiny authored fixtures, not working Builder binaries**. Please record the hashes, `Get-AuthenticodeSignature` / `signtool verify /pa /v` results and timestamp details; untrusted roots are expected. Do not import these certificates as trusted roots or disable Defender. No CI build is needed to produce those fixtures yourself. Your browser-download and next-release checks need real release files; those builds remain the maintainers' to start, and these fixtures make no promise about their verdicts.
HumanBean added this to the v3.0.0 milestone 2026-11-13 17:45:06 +00:00
HumanBean approved these changes 2026-11-13 17:45:10 +00:00
Dismissed
HumanBean left a comment

Reviewed with security in mind, against main fbd42024. Main's release workflows (release.yml, build-windows.yml), builder_package.py and check_windows_files.py haven't changed since this branch's base dd02c6cc, so it is current and still fits the Linux-only, command-line Builder release flow (no MSI, Python Builder or Windows runner comes back). I only read the code: I didn't run the checks or the signer.

What's good

  • Secrets stay out of PRs. Only release.yml (push to main or a v* tag) passes the three secrets, by name and not with secrets: inherit. build-windows.yml has no pull_request trigger, and the signing step checks push, this repository, inputs.ref == github.sha and main or a tag, with certificate_for checking the same again in Python. The PIN goes in on stdin (-readpass -, which osslsigncode 2.15 supports) and is removed from the child's environment. The child's output is never printed, so provider errors can't leak anything. The PKCS#11 URI is restricted to selector fields, and pin-*, query strings and file keys are refused. The public chain goes into a 0600 temporary file that is deleted afterwards.
  • The tool is pinned. osslsigncode 2.15 linux-amd64 is fetched through tools.fetch, and its size and SHA-256 match GitHub's asset digest for the 2.15 release (5d2890c1…, 2335349 bytes). It is unpacked fresh from that checked archive on every run.
  • Timestamping and verification. Signing uses SHA-256 and an RFC 3161 timestamp (-ts). If the timestamp server is down, osslsigncode fails and the job stops, so nothing ships unsigned by mistake. Each file is verified with the leaf pinned (-require-leaf-hash), and the check requires the line Timestamp Server Signature verification: ok instead of trusting exit 0. The originals are only replaced once both files have verified.
  • Order. Signing comes after dotnet publish and before builder_package.py package/zip. The finished zip is checked for all three EXE paths before upload, and release.yml computes SHA256SUMS and its signature from that zip. So the published hashes cover the signed bytes. make_game.py copies the signed setup into the game folder unchanged.
  • Unsigned by default. With no certificate, the run logs not signed: no certificate configured, downloads nothing and goes on as before. A malformed certificate fails the version job.
  • Docs. RELEASING.md, docs/releases.md and docs/forgejo-runners.md say what is signed, what isn't, the custody decisions still open and that "a label is not an access control".

Must change

  • Nothing blocks merging an off-by-default step.

Should change (before anyone turns signing on)

  1. The trust anchor. verify_file checks the signer chain against /etc/ssl/certs/ca-certificates.crt. On Ubuntu/Debian that bundle holds only the Mozilla roots trusted for TLS, and Mozilla carries no code-signing-only roots. A certificate that chains to a code-signing root such as Sectigo's Public Code Signing Root R46, GlobalSign's code-signing R45, SSL.com's code-signing roots or Microsoft's Identity Verification Root (Artifact Signing) will most likely fail verification, safely but with only the generic "failed" message. DigiCert (Trusted Root G4) would pass. Better to let maintainers pin the root, for example an AUTHENTICODE_ROOT_PEM variable or the last certificate of the chain, and use it as -CAfile. Keep the system bundle for -HTTPS-CAfile and the timestamp chain.
  2. Run signing in its own small job. The PIN and the token are reachable in the same job that ran dotnet publish (MSBuild targets from NuGet packages restored with NuGetAudit=false, and build-server processes that stay running), apt-get install and Wine. A small job that only downloads the unsigned files, signs and verifies them, then packages, would keep build-time code away from the key. That job would be the only one needing linux-signing, instead of the whole package build with Wine and dav1d.
  3. LongshotBuilder/LongshotBuilder.dll is not signed. It is the Builder's own code (PublishSingleFile is false, so the apphost only loads it) and is scanned as much as the EXE. osslsigncode can sign it in the same sign_files call, and ARCHIVE_FILES can check it.
  4. Only one timestamp server. osslsigncode 2.15 accepts several -ts options and tries them in turn. A second RFC 3161 server (Sectigo's, say) would stop a DigiCert outage from blocking a release, and it would still fail closed if all of them are down.

What I couldn't check

  • I couldn't confirm that Forgejo evaluates inputs.* in a called workflow's runs-on. If it doesn't, the expression falls back to ubuntu-24.04, so unsigned releases are unaffected. A signed run, though, would put the PIN on an ordinary runner, where it would fail for lack of the PKCS#11 module. Check this with the canary that docs/releases.md already asks for.
  • I couldn't check a real provider, the real timestamp server, or whether Windows trusts the result. That is T-RSMT's job, as the description says.
Reviewed with security in mind, against main `fbd42024`. Main's release workflows (`release.yml`, `build-windows.yml`), `builder_package.py` and `check_windows_files.py` haven't changed since this branch's base `dd02c6cc`, so it is current and still fits the Linux-only, command-line Builder release flow (no MSI, Python Builder or Windows runner comes back). I only read the code: I didn't run the checks or the signer. **What's good** - **Secrets stay out of PRs.** Only `release.yml` (push to main or a `v*` tag) passes the three secrets, by name and not with `secrets: inherit`. `build-windows.yml` has no `pull_request` trigger, and the signing step checks push, this repository, `inputs.ref == github.sha` and main or a tag, with `certificate_for` checking the same again in Python. The PIN goes in on stdin (`-readpass -`, which osslsigncode 2.15 supports) and is removed from the child's environment. The child's output is never printed, so provider errors can't leak anything. The PKCS#11 URI is restricted to selector fields, and `pin-*`, query strings and file keys are refused. The public chain goes into a 0600 temporary file that is deleted afterwards. - **The tool is pinned.** osslsigncode 2.15 linux-amd64 is fetched through `tools.fetch`, and its size and SHA-256 match GitHub's asset digest for the 2.15 release (`5d2890c1…`, 2335349 bytes). It is unpacked fresh from that checked archive on every run. - **Timestamping and verification.** Signing uses SHA-256 and an RFC 3161 timestamp (`-ts`). If the timestamp server is down, osslsigncode fails and the job stops, so nothing ships unsigned by mistake. Each file is verified with the leaf pinned (`-require-leaf-hash`), and the check requires the line `Timestamp Server Signature verification: ok` instead of trusting exit 0. The originals are only replaced once both files have verified. - **Order.** Signing comes after `dotnet publish` and before `builder_package.py package`/`zip`. The finished zip is checked for all three EXE paths before upload, and `release.yml` computes SHA256SUMS and its signature from that zip. So the published hashes cover the signed bytes. `make_game.py` copies the signed setup into the game folder unchanged. - **Unsigned by default.** With no certificate, the run logs `not signed: no certificate configured`, downloads nothing and goes on as before. A malformed certificate fails the version job. - **Docs.** RELEASING.md, docs/releases.md and docs/forgejo-runners.md say what is signed, what isn't, the custody decisions still open and that "a label is not an access control". **Must change** - Nothing blocks merging an off-by-default step. **Should change (before anyone turns signing on)** 1. **The trust anchor.** `verify_file` checks the signer chain against `/etc/ssl/certs/ca-certificates.crt`. On Ubuntu/Debian that bundle holds only the Mozilla roots trusted for TLS, and Mozilla carries no code-signing-only roots. A certificate that chains to a code-signing root such as Sectigo's Public Code Signing Root R46, GlobalSign's code-signing R45, SSL.com's code-signing roots or Microsoft's Identity Verification Root (Artifact Signing) will most likely fail verification, safely but with only the generic "failed" message. DigiCert (Trusted Root G4) would pass. Better to let maintainers pin the root, for example an `AUTHENTICODE_ROOT_PEM` variable or the last certificate of the chain, and use it as `-CAfile`. Keep the system bundle for `-HTTPS-CAfile` and the timestamp chain. 2. **Run signing in its own small job.** The PIN and the token are reachable in the same job that ran `dotnet publish` (MSBuild targets from NuGet packages restored with `NuGetAudit=false`, and build-server processes that stay running), `apt-get install` and Wine. A small job that only downloads the unsigned files, signs and verifies them, then packages, would keep build-time code away from the key. That job would be the only one needing `linux-signing`, instead of the whole package build with Wine and dav1d. 3. **`LongshotBuilder/LongshotBuilder.dll` is not signed.** It is the Builder's own code (`PublishSingleFile` is false, so the apphost only loads it) and is scanned as much as the EXE. osslsigncode can sign it in the same `sign_files` call, and `ARCHIVE_FILES` can check it. 4. **Only one timestamp server.** osslsigncode 2.15 accepts several `-ts` options and tries them in turn. A second RFC 3161 server (Sectigo's, say) would stop a DigiCert outage from blocking a release, and it would still fail closed if all of them are down. **What I couldn't check** - I couldn't confirm that Forgejo evaluates `inputs.*` in a called workflow's `runs-on`. If it doesn't, the expression falls back to `ubuntu-24.04`, so unsigned releases are unaffected. A signed run, though, would put the PIN on an ordinary runner, where it would fail for lack of the PKCS#11 module. Check this with the canary that docs/releases.md already asks for. - I couldn't check a real provider, the real timestamp server, or whether Windows trusts the result. That is T-RSMT's job, as the description says.
@ -59,1 +73,3 @@
runs-on: ubuntu-24.04
# Both paths build on Linux. Runner admission/provider authorization must deny PR access to the signing identity;
# a label alone is not an access control (docs/releases.md, Windows Authenticode).
runs-on: ${{ inputs.authenticode && github.event_name == 'push' && github.repository == 'MagicBeanDev/Longshot' && inputs.ref == github.sha && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) && 'linux-signing' || 'ubuntu-24.04' }}
Owner

Worth confirming on the canary: does Forgejo evaluate inputs.* in a called workflow's runs-on? If not, this falls back to ubuntu-24.04. Unsigned releases are fine either way, but a signed run would then hand the PIN to an ordinary runner (where it would fail for lack of the PKCS#11 module).

Worth confirming on the canary: does Forgejo evaluate `inputs.*` in a called workflow's `runs-on`? If not, this falls back to `ubuntu-24.04`. Unsigned releases are fine either way, but a signed run would then hand the PIN to an ordinary runner (where it would fail for lack of the PKCS#11 module).
@ -128,1 +144,4 @@
# Sign the two project executables before either copy of the setup is packaged, and before release.yml hashes the zip.
# No signing secret is passed to a PR step, including same-repository PRs and pull_request_target.
- name: Authenticode (optional)
Owner

The token PIN and key access arrive in the same job that has already run dotnet publish (MSBuild targets from NuGet packages restored with NuGetAudit=false, plus build-server processes that stay running) and apt-get install, and that will run Wine afterwards. A small separate signing job (download the unsigned setup and publish folder, sign and verify, then package) would keep build-time code away from the key. It would also mean only that job needs linux-signing, not the whole package build.

The token PIN and key access arrive in the same job that has already run `dotnet publish` (MSBuild targets from NuGet packages restored with `NuGetAudit=false`, plus build-server processes that stay running) and `apt-get install`, and that will run Wine afterwards. A small separate signing job (download the unsigned setup and publish folder, sign and verify, then package) would keep build-time code away from the key. It would also mean only that job needs `linux-signing`, not the whole package build.
@ -0,0 +28,4 @@
REPOSITORY = "MagicBeanDev/Longshot"
CERTIFICATE = "AUTHENTICODE_CERTIFICATE_PEM"
TIMESTAMP = "https://timestamp.digicert.com"
CA_FILE = "/etc/ssl/certs/ca-certificates.crt"
Owner

This bundle is Debian/Ubuntu's TLS (serverAuth) roots from Mozilla, which carries no code-signing-only roots. A certificate chaining to, for example, Sectigo Public Code Signing Root R46, GlobalSign Code Signing Root R45, SSL.com's code-signing roots or Microsoft Identity Verification Root (Artifact Signing) will most likely fail verify here (safely, but with only the generic failure message). DigiCert's Trusted Root G4 would pass. Suggest a maintainer-pinned signer root (a variable, or the chain's last certificate) for -CAfile, keeping the system bundle for -HTTPS-CAfile and the timestamp chain.

This bundle is Debian/Ubuntu's TLS (serverAuth) roots from Mozilla, which carries no code-signing-only roots. A certificate chaining to, for example, Sectigo Public Code Signing Root R46, GlobalSign Code Signing Root R45, SSL.com's code-signing roots or Microsoft Identity Verification Root (Artifact Signing) will most likely fail `verify` here (safely, but with only the generic failure message). DigiCert's Trusted Root G4 would pass. Suggest a maintainer-pinned signer root (a variable, or the chain's last certificate) for `-CAfile`, keeping the system bundle for `-HTTPS-CAfile` and the timestamp chain.
@ -0,0 +140,4 @@
for file in files:
tmp = Path(stack.enter_context(tempfile.TemporaryDirectory(prefix=".authenticode-", dir=file.parent)))
signed = tmp / "signed.exe"
run_tool([tool, "sign", "-certs", certificate, *key_args, "-h", "sha256", "-ts", timestamp,
Owner

osslsigncode 2.15 accepts several -ts options and tries each in turn. A second RFC 3161 server (Sectigo's, say) would keep a DigiCert outage from blocking a release, and it still fails closed when every server is down.

osslsigncode 2.15 accepts several `-ts` options and tries each in turn. A second RFC 3161 server (Sectigo's, say) would keep a DigiCert outage from blocking a release, and it still fails closed when every server is down.
@ -0,0 +160,4 @@
cert.write_text(pem, encoding="ascii")
cert.chmod(0o600)
with pinned_tool(cache) as tool:
sign_files([setup, Path(publish) / "LongshotBuilder.exe"], tool, cert, args, password=pin + "\n")
Owner

LongshotBuilder/LongshotBuilder.dll (PublishSingleFile is false) is the Builder's own code: the apphost only loads it. It stays unsigned, and Defender scans it as much as the EXE. Could it be signed in this same call and added to ARCHIVE_FILES?

`LongshotBuilder/LongshotBuilder.dll` (`PublishSingleFile` is false) is the Builder's own code: the apphost only loads it. It stays unsigned, and Defender scans it as much as the EXE. Could it be signed in this same call and added to `ARCHIVE_FILES`?
HumanBean force-pushed ci/windows-authenticode from c2e7d22877
Some checks failed
build-windows.yml / test(release): export public Authenticode stand-ins for Windows checks (pull_request) Failing after 0s
PR description / Description follows the template (pull_request_target) Successful in 3s
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been cancelled
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
Third-party parts / As their notes say (pull_request) Has been cancelled
Third-party parts / The same as upstream (pull_request) Has been cancelled
Website / Website on Cloudflare (pull_request) Has been cancelled
to b784ce0e10
Some checks failed
build-windows.yml / ci(windows): pin the signer's root, add a second timestamp server, sign LongshotBuilder.dll (pull_request) Failing after 0s
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 3s
CI / Workflow lint (actionlint) (pull_request) Successful in 5s
Third-party parts / As their notes say (pull_request) Successful in 5s
PR description / Description follows the template (pull_request_target) Successful in 6s
2026-11-13 17:52:05 +00:00
Compare
HumanBean dismissed HumanBean's review 2026-11-13 17:52:06 +00:00
Reason:

New commits pushed, approval review dismissed automatically according to repository settings

Owner

I rebased this onto main @ fbd42024 (no conflicts) and addressed my review's five comments in one commit on top of yours. It's pushed as b784ce0e, and your four commits are unchanged apart from the rebase. No tests, builds or workflows were run here; only the edited Python files were parsed.

My commit, "pin the signer's root, add a second timestamp server, sign LongshotBuilder.dll":

  1. Trust root. A new repository variable, AUTHENTICODE_ROOT_PEM, holds the issuing CA's public root, optionally followed by intermediates. It is used as -CAfile for the signer chain in both the signing step and the packaged check (check_windows_files.py through auth.trust_anchor).
    • The timestamp and HTTPS checks keep the system bundle.
    • Unset: the runner's system bundle, and the log says it holds TLS roots only.
    • Set: the log shows its SHA-256 fingerprint.
    • Malformed: the job stops; it never falls back.
  2. Timestamps. DigiCert, then Sectigo (http://timestamp.sectigo.com), each passed as its own -ts. It still fails closed when both are down, and verification still requires a verified timestamp.
  3. LongshotBuilder/LongshotBuilder.dll is signed in the same call as the setup and the apphost. ARCHIVE_FILES now has four paths, so the zip check covers it. tests/test_authenticode.py, tests/authenticode_standin.py and tests/test_authenticode_standin.py are updated to match.
  4. Separate signing job: documented, not restructured. Splitting it changes the path release.yml takes for every release. It needs two more artifact transfers of the publish folder, and Forgejo's handling of a skipped job in needs is something only a real run can confirm. So RELEASING.md now has a "known limitation and follow-up" paragraph, before signing is turned on for players, describing the target design: build, then a hash-checked signing job, then packaging from the signed files. There's a comment at the step too. dotnet publish now passes --disable-build-servers, so no MSBuild or compiler server outlives the build.
  5. inputs.* in runs-on. A new "Signing runner" step, holding no secrets, runs authenticode.py runner before signing. It fails with "not on the signing runner … the token PIN is not used" unless the PKCS#11 provider and module exist. sign_builder checks again before reading the PIN. The docs describe the canary to look for on linux-signing, and say the token must still be bound to the signing machine, since this check stops the PIN being used elsewhere, not sent there.

Please update the description with:

  • the three signed files and the four checked paths;
  • AUTHENTICODE_ROOT_PEM;
  • the two timestamp servers;
  • the signing-runner step and its canary;
  • the same-job signing as a documented limitation.

Also re-check the testing boxes: run_all hasn't run since these changes. The live checks (T-RSMT) are still open.

I rebased this onto main @ fbd42024 (no conflicts) and addressed my review's five comments in one commit on top of yours. It's pushed as b784ce0e, and your four commits are unchanged apart from the rebase. No tests, builds or workflows were run here; only the edited Python files were parsed. **My commit**, "pin the signer's root, add a second timestamp server, sign LongshotBuilder.dll": 1. **Trust root.** A new repository variable, `AUTHENTICODE_ROOT_PEM`, holds the issuing CA's public root, optionally followed by intermediates. It is used as `-CAfile` for the signer chain in both the signing step and the packaged check (`check_windows_files.py` through `auth.trust_anchor`). - The timestamp and HTTPS checks keep the system bundle. - Unset: the runner's system bundle, and the log says it holds TLS roots only. - Set: the log shows its SHA-256 fingerprint. - Malformed: the job stops; it never falls back. 2. **Timestamps.** DigiCert, then Sectigo (`http://timestamp.sectigo.com`), each passed as its own `-ts`. It still fails closed when both are down, and verification still requires a verified timestamp. 3. **`LongshotBuilder/LongshotBuilder.dll` is signed** in the same call as the setup and the apphost. `ARCHIVE_FILES` now has four paths, so the zip check covers it. `tests/test_authenticode.py`, `tests/authenticode_standin.py` and `tests/test_authenticode_standin.py` are updated to match. 4. **Separate signing job: documented, not restructured.** Splitting it changes the path `release.yml` takes for every release. It needs two more artifact transfers of the publish folder, and Forgejo's handling of a skipped job in `needs` is something only a real run can confirm. So RELEASING.md now has a "known limitation and follow-up" paragraph, before signing is turned on for players, describing the target design: build, then a hash-checked signing job, then packaging from the signed files. There's a comment at the step too. `dotnet publish` now passes `--disable-build-servers`, so no MSBuild or compiler server outlives the build. 5. **`inputs.*` in `runs-on`.** A new "Signing runner" step, holding no secrets, runs `authenticode.py runner` before signing. It fails with "not on the signing runner … the token PIN is not used" unless the PKCS#11 provider and module exist. `sign_builder` checks again before reading the PIN. The docs describe the canary to look for on `linux-signing`, and say the token must still be bound to the signing machine, since this check stops the PIN being *used* elsewhere, not *sent* there. **Please update the description** with: - the three signed files and the four checked paths; - `AUTHENTICODE_ROOT_PEM`; - the two timestamp servers; - the signing-runner step and its canary; - the same-job signing as a documented limitation. Also re-check the testing boxes: `run_all` hasn't run since these changes. The live checks (T-RSMT) are still open.
HumanBean left a comment

Re-review at b784ce0e, on main@origin fbd42024 (current, so "Rebased on current main" holds). I read the four commits and the fix commit with security in mind. I ran nothing: no checks, no signer, no build. For osslsigncode I read the 2.15 source (osslsigncode.c).

Earlier points (the approving review, id 1140, at c2e7d228)

  1. Trust anchor: resolved.

    • AUTHENTICODE_ROOT_PEM is a vars. value, never placed beside a secret on its own. The signing step and the packaged check both use it as -CAfile.
    • -TSA-CAfile and -HTTPS-CAfile stay on the system bundle (tsa_ca_file=CA_FILE in sign_builder and in check_windows_files.authenticode).
    • When it is unset, the system bundle is used and logged. When it is set, it goes through fingerprint(), so it must hold certificates only: anything malformed, a private key or trailing text raises SigningError before any file is written. Both callers turn that into a failure; there's no fallback.
    • The temporary root goes in a 0700 TemporaryDirectory that is removed on every exit path, including an exception at the yield. The file itself takes the umask, which is fine for a public root.
  2. Separate signing job: resolved as a documented limitation. It's documented in RELEASING.md, docs/releases.md and a comment at the step, and --disable-build-servers is added. --disable-build-servers exists since .NET SDK 7.0.100, the workflow pins 8.0.x, and tests/test_portable_builder_startup.py already passes it to dotnet publish. It's harmless for unsigned builds.

  3. LongshotBuilder.dll: resolved. It is listed the same way everywhere:

    • PUBLISH_FILES, signed in the same sign_files call;
    • ARCHIVE_FILES, four paths, which builder_package.package produces through copytree of the publish folder;
    • the stand-in, its export and both test files.

    Signing still comes before Package folder/The zip, and release.yml still hashes the finished zip, so SHA256SUMS covers the signed bytes.

  4. Second timestamp server: resolved.

    • osslsigncode 2.15 keeps each -ts in tsurl[], and add_timestamp_rfc3161 returns at the first server that succeeds. It fails ("RFC 3161 timestamping failed") only when all of them fail.
    • -HTTPS-CAfile applies only to https URLs. A plain-HTTP RFC 3161 token is acceptable: it is signed, and signing doesn't check it, but verify does. verify_timestamp checks the token against -TSA-CAfile and the message imprint. verify_signature prints Timestamp Server Signature verification: ok|failed, and for a file with no timestamp it prints Timestamp is not available, which verify_file still rejects.
    • So an HTTP man-in-the-middle can only make the run fail, and a bad token fails before any original is replaced.
  5. inputs.* in runs-on: resolved as far as a review can go. The "Signing runner" step has the same if: as the signing step (the test compares them), and it has no secrets.. A failing step skips the later steps through the implicit success(), and it can't be skipped while signing runs. sign_builder calls key_arguments (the provider and module files) before the PIN is touched, and the test covers that. The docs say the secrets still reach whichever runner takes the job.

The tests, read but not run. They match the code as far as I can tell:

  • the signatures of sign_files, verify_file, verify_archive and sign_builder;
  • 6 calls for 3 files;
  • the -ts order from TIMESTAMPS;
  • [2:6] of the verify arguments;
  • .dll as the fourth suffix;
  • the workflow string checks: dotnet publish builder/ first appears at the publish step, and the "Signing runner" section has no secrets.;
  • the standin mocks with four ARCHIVE_FILES, with byte 256 still 0xc3 in the DLL.

Must change

  • The description and checklist describe c2e7d228, not this head. Out of date:

    • "Branch state";
    • "all three project EXE paths";
    • a single DigiCert endpoint;
    • "The verifier uses the Linux runner's CA bundle";
    • no AUTHENTICODE_ROOT_PEM row in the flags table;
    • no "Signing runner" step;
    • no same-job limitation.

    "run_all.py run and passing" is ticked, but nothing has run since the fix commit changed authenticode.py, check_windows_files.py and three test files. Run python -I -B tests/run_all.py (at least test_authenticode.py, test_authenticode_standin.py and test_windows_on_linux.py) at b784ce0e, then update the description and boxes as asked in the maintainer's comment. The code itself needs no change before merging with signing off.

Should change (before anyone turns signing on)

  • The Sectigo fallback's timestamp chain is unconfirmed against the runner's Mozilla bundle (inline). It fails closed, but it could be a fallback that never works.
  • The runner check is file presence only (inline). Choose provider and module paths that exist only on the signing runner.

What I couldn't check

  • Forgejo's runs-on evaluation, a real provider, the live DigiCert and Sectigo servers, and Windows trust. These are T-RSMT and the canary.
  • Whether the local osslsigncode.c I read is byte-for-byte the 2.15 tag. It matches the 2.15 raw file's size within a few bytes.
Re-review at b784ce0e, on main@origin `fbd42024` (current, so "Rebased on current main" holds). I read the four commits and the fix commit with security in mind. I ran nothing: no checks, no signer, no build. For osslsigncode I read the 2.15 source (`osslsigncode.c`). **Earlier points (the approving review, id 1140, at c2e7d228)** 1. **Trust anchor: resolved.** - `AUTHENTICODE_ROOT_PEM` is a `vars.` value, never placed beside a secret on its own. The signing step and the packaged check both use it as `-CAfile`. - `-TSA-CAfile` and `-HTTPS-CAfile` stay on the system bundle (`tsa_ca_file=CA_FILE` in `sign_builder` and in `check_windows_files.authenticode`). - When it is unset, the system bundle is used and logged. When it is set, it goes through `fingerprint()`, so it must hold certificates only: anything malformed, a private key or trailing text raises `SigningError` before any file is written. Both callers turn that into a failure; there's no fallback. - The temporary root goes in a 0700 `TemporaryDirectory` that is removed on every exit path, including an exception at the `yield`. The file itself takes the umask, which is fine for a public root. 2. **Separate signing job: resolved as a documented limitation.** It's documented in RELEASING.md, docs/releases.md and a comment at the step, and `--disable-build-servers` is added. `--disable-build-servers` exists since .NET SDK 7.0.100, the workflow pins `8.0.x`, and `tests/test_portable_builder_startup.py` already passes it to `dotnet publish`. It's harmless for unsigned builds. 3. **`LongshotBuilder.dll`: resolved.** It is listed the same way everywhere: - `PUBLISH_FILES`, signed in the same `sign_files` call; - `ARCHIVE_FILES`, four paths, which `builder_package.package` produces through `copytree` of the publish folder; - the stand-in, its export and both test files. Signing still comes before `Package folder`/`The zip`, and `release.yml` still hashes the finished zip, so SHA256SUMS covers the signed bytes. 4. **Second timestamp server: resolved.** - osslsigncode 2.15 keeps each `-ts` in `tsurl[]`, and `add_timestamp_rfc3161` returns at the first server that succeeds. It fails ("RFC 3161 timestamping failed") only when all of them fail. - `-HTTPS-CAfile` applies only to `https` URLs. A plain-HTTP RFC 3161 token is acceptable: it is signed, and signing doesn't check it, but `verify` does. `verify_timestamp` checks the token against `-TSA-CAfile` and the message imprint. `verify_signature` prints `Timestamp Server Signature verification: ok|failed`, and for a file with no timestamp it prints `Timestamp is not available`, which `verify_file` still rejects. - So an HTTP man-in-the-middle can only make the run fail, and a bad token fails before any original is replaced. 5. **`inputs.*` in `runs-on`: resolved as far as a review can go.** The "Signing runner" step has the same `if:` as the signing step (the test compares them), and it has no `secrets.`. A failing step skips the later steps through the implicit `success()`, and it can't be skipped while signing runs. `sign_builder` calls `key_arguments` (the provider and module files) before the PIN is touched, and the test covers that. The docs say the secrets still reach whichever runner takes the job. **The tests, read but not run.** They match the code as far as I can tell: - the signatures of `sign_files`, `verify_file`, `verify_archive` and `sign_builder`; - 6 calls for 3 files; - the `-ts` order from `TIMESTAMPS`; - `[2:6]` of the verify arguments; - `.dll` as the fourth suffix; - the workflow string checks: `dotnet publish builder/` first appears at the publish step, and the "Signing runner" section has no `secrets.`; - the standin mocks with four `ARCHIVE_FILES`, with byte 256 still `0xc3` in the DLL. **Must change** - **The description and checklist describe c2e7d228, not this head.** Out of date: - "Branch state"; - "all three project EXE paths"; - a single DigiCert endpoint; - "The verifier uses the Linux runner's CA bundle"; - no `AUTHENTICODE_ROOT_PEM` row in the flags table; - no "Signing runner" step; - no same-job limitation. "`run_all.py` run and passing" is ticked, but nothing has run since the fix commit changed `authenticode.py`, `check_windows_files.py` and three test files. Run `python -I -B tests/run_all.py` (at least `test_authenticode.py`, `test_authenticode_standin.py` and `test_windows_on_linux.py`) at b784ce0e, then update the description and boxes as asked in [the maintainer's comment](https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/98). The code itself needs no change before merging with signing off. **Should change (before anyone turns signing on)** - **The Sectigo fallback's timestamp chain is unconfirmed** against the runner's Mozilla bundle (inline). It fails closed, but it could be a fallback that never works. - **The runner check is file presence only** (inline). Choose provider and module paths that exist only on the signing runner. **What I couldn't check** - Forgejo's `runs-on` evaluation, a real provider, the live DigiCert and Sectigo servers, and Windows trust. These are T-RSMT and the canary. - Whether the local `osslsigncode.c` I read is byte-for-byte the 2.15 tag. It matches the 2.15 raw file's size within a few bytes.
@ -102,0 +207,4 @@
Mozilla's TLS roots only, so a certificate chaining to a code-signing-only root fails verification there (safely);
DigiCert Trusted Root G4 is one that would pass. A set but malformed value stops the job rather than falling back.
The timestamp chains and the timestamp servers' HTTPS are always checked against the system bundle (DigiCert's and
Sectigo's timestamp roots are in it). Take the root from the CA's own repository, compare its fingerprint with the
Owner

"DigiCert's and Sectigo's timestamp roots are in it": true for DigiCert (Trusted Root G4). For Sectigo it holds only if the token carries the USERTrust cross-signature of Public Time Stamping Root R46 (see the comment on TIMESTAMPS). Please confirm it, or soften the claim until a canary has exercised the fallback.

"DigiCert's and Sectigo's timestamp roots are in it": true for DigiCert (Trusted Root G4). For Sectigo it holds only if the token carries the USERTrust cross-signature of *Public Time Stamping Root R46* (see the comment on `TIMESTAMPS`). Please confirm it, or soften the claim until a canary has exercised the fallback.
@ -0,0 +36,4 @@
CERTIFICATE = "AUTHENTICODE_CERTIFICATE_PEM"
ROOT_CERTIFICATE = "AUTHENTICODE_ROOT_PEM"
# RFC 3161 servers, tried in turn (several -ts): one server's outage does not stop a release, all of them down does.
TIMESTAMPS = ("https://timestamp.digicert.com", "http://timestamp.sectigo.com")
Owner

The multi--ts fallback is right: osslsigncode 2.15's add_timestamp_rfc3161 stops at the first server that succeeds. A plain-HTTP RFC 3161 server is acceptable too, since verify checks the token's chain and imprint.

One thing to confirm before relying on it: the token's chain must reach a root in /etc/ssl/certs/ca-certificates.crt, because that's the -TSA-CAfile here. Sectigo's current TSA chain ends at Sectigo Public Time Stamping Root R46. That root isn't a Mozilla TLS root, so the chain only verifies if the token carries R46 cross-signed by USERTrust RSA. I couldn't check what the token carries. If it doesn't, a release timestamped by Sectigo fails verify. That is safe, but the fallback would never actually work.

Worth one canary run with DigiCert blocked, or else add the timestamp roots to the trust bundle explicitly.

The multi-`-ts` fallback is right: osslsigncode 2.15's `add_timestamp_rfc3161` stops at the first server that succeeds. A plain-HTTP RFC 3161 server is acceptable too, since `verify` checks the token's chain and imprint. One thing to confirm before relying on it: the token's chain must reach a root in `/etc/ssl/certs/ca-certificates.crt`, because that's the `-TSA-CAfile` here. Sectigo's current TSA chain ends at *Sectigo Public Time Stamping Root R46*. That root isn't a Mozilla TLS root, so the chain only verifies if the token carries R46 cross-signed by USERTrust RSA. I couldn't check what the token carries. If it doesn't, a release timestamped by Sectigo fails `verify`. That is safe, but the fallback would never actually work. Worth one canary run with DigiCert blocked, or else add the timestamp roots to the trust bundle explicitly.
@ -0,0 +106,4 @@
"""True when this runner has the PKCS#11 libraries. Run in a step that holds no secret, before the one that does."""
for name in PKCS11_PATHS:
path = Path(env.get(name, ""))
if not env.get(name) or not path.is_absolute() or not path.is_file():
Owner

Nit: this only checks that the files exist. A provider or module path that also exists on a stock Ubuntu runner, for example a distro package's pkcs11-provider or p11-kit path, would pass this check off the signing runner, and then the PIN goes to the module. Worth a sentence in docs/releases.md step 2: install the libraries at a path only the signing runner has (e.g. under /opt/...).

Nit: this only checks that the files exist. A provider or module path that also exists on a stock Ubuntu runner, for example a distro package's `pkcs11-provider` or `p11-kit` path, would pass this check off the signing runner, and then the PIN goes to the module. Worth a sentence in docs/releases.md step 2: install the libraries at a path only the signing runner has (e.g. under `/opt/...`).
Author
Contributor

Thanks for the rebase and your own commit b784ce0e. The description now describes that head, including the DLL, pinned signer root, ordered timestamp fallback, runner canary and same-job limitation.

Source suite at b784ce0e: 516 passed, 0 failed, 15 skipped (main fbd42024: 514 passed, 0 failed, 15 skipped).

The Sectigo fallback chain is still unconfirmed against the runner's bundle, and the runner check only tests file presence. Both remain open before signing is enabled and are named in Risk; neither is fixed by this description update.

Thanks for the rebase and your own commit b784ce0e. The description now describes that head, including the DLL, pinned signer root, ordered timestamp fallback, runner canary and same-job limitation. Source suite at b784ce0e: 516 passed, 0 failed, 15 skipped (main `fbd42024`: 514 passed, 0 failed, 15 skipped). The Sectigo fallback chain is still unconfirmed against the runner's bundle, and the runner check only tests file presence. Both remain open before signing is enabled and are named in Risk; neither is fixed by this description update.
Some checks failed
build-windows.yml / ci(windows): pin the signer's root, add a second timestamp server, sign LongshotBuilder.dll (pull_request) Failing after 0s
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 3s
CI / Workflow lint (actionlint) (pull_request) Successful in 5s
Third-party parts / As their notes say (pull_request) Successful in 5s
PR description / Description follows the template (pull_request_target) Successful in 6s
This pull request doesn't have enough approvals yet. 0 of 1 approvals granted.
Some workflows are waiting to be reviewed.
This branch is out-of-date with the base branch
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u 'ci/windows-authenticode:mooki-ci/windows-authenticode'
git switch 'mooki-ci/windows-authenticode'
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
MagicBeanDev/Longshot!98
No description provided.