Skip to content

fix(update): check the installer's Authenticode signer before running it - #469

Merged
KnifeLemon merged 3 commits into
KnifeLemon:mainfrom
Bddgc:fix/verify-update-signature
Oct 23, 2026
Merged

KnifeLemon merged 3 commits into
KnifeLemon:mainfrom
Bddgc:fix/verify-update-signature

Conversation

@Bddgc

@Bddgc Bddgc commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

What does this change?

Before the in-app updater runs a downloaded installer, it now checks that Windows trusts the installer's Authenticode signature and that the publisher who signed the running Filee signed it too. Today it only checks the SHA-256 from Filee-<version>-SHA256SUMS.txt, which sits on the same release as the installer: anyone who could replace the installer (a leaked token, a compromised account or workflow) could replace the checksum file as well, and every installed copy would run the replacement elevated.

When the protection takes effect

The published releases are not signed yet. As of v2.0.0, neither Filee-2.0.0-win-Setup.exe nor Filee.exe in the portable zip has an Authenticode signature (Get-AuthenticodeSignature → NotSigned).

A copy that isn't signed itself has no publisher to compare with, so it keeps to the SHA-256 check exactly as today. This change therefore does not break updates from any version released so far. The protection starts with the first version that ships signed (build/sign-release.ps1): from then on, that copy refuses any installer not signed by the same publisher. After that, every Windows release has to stay signed with the same certificate subject, or signed copies will refuse to update in-app and send users to the download page instead.

Details

  • src/Filee.Platform.Windows/Authenticode.cs (new): Authenticode.TrustedSigner(path, checkRevocation) asks WinVerifyTrust (WINTRUST_ACTION_GENERIC_VERIFY_V2, no UI) whether the signature is trusted, then reads the signer certificate from that same verification state (WTHelperProvDataFromStateData → signer → certificate) and returns its subject. It returns null for an unsigned or untrusted file, and off Windows.
  • UpdateService.CheckPublisher runs between the download and Process.Start. The installer is checked with revocation (whole chain, root excluded); the running copy without. A different or missing signer deletes the installer and throws InvalidDataException, which takes the existing failure path (alert, then the download page).
  • The subject is compared, not the thumbprint, so renewing the certificate with the same details keeps updates working.
  • SECURITY.md describes the new check.

Type of change

  • Bug fix
  • New feature or improvement
  • New format or engine
  • Translation
  • Documentation
  • Build, CI or dependencies

How was it tested?

  • dotnet format --verify-no-changes (on the changed files, --severity warn as in CI)
  • dotnet test: Filee.App.Tests filtered to UpdateServiceTests (30 passed, 7 of them new), Windows 11, .NET SDK 10.0.402; the rest of the suite was not run
  • Tried it in the app: not done. A real update needs a signed release to update to.

New tests: installer from the same publisher is kept; installer from another publisher, or unsigned/untrusted, is deleted and refused; the installer is checked with revocation and the running copy without; an unsigned copy keeps to the checksum; TrustedSigner returns null for an unsigned file and a Microsoft subject for dotnet.exe (a real WinVerifyTrust call, skipped off Windows). dotnet build -c Release adds no warnings.

Checklist

  • New UI strings are in all src/Filee.App/Assets/i18n/*.json files (none added)
  • New third-party code or binaries are listed in THIRD-PARTY-NOTICES.md (none added)
  • The PR does one thing (unrelated changes go in a separate PR)

🤖 Generated with Claude Code

The installer and its SHA256SUMS file sit on the same GitHub release, so the
checksum only shows the download is intact: whoever could replace the
installer could replace the checksum too. The updater now also asks
WinVerifyTrust whether the installer's signature is trusted (revocation
included) and compares its signer with the publisher that signed the running
Filee. A different or missing signer discards the installer and falls back to
the download page. Copies that are not signed themselves (built from source)
keep to the checksum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added the bug Something isn't working label Oct 23, 2026

@KnifeLemon KnifeLemon left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot @Bddgc, this is a good addition, and the timing is right: the next release is the first one we sign (Certum, "Open Source Developer MYEONGGU KIM"), so the check would be active from the first signed copy on.

I went through it and tried it:

  • Authenticode.TrustedSigner on a Setup signed with our certificate returns CN=Open Source Developer MYEONGGU KIM, O=Open Source Developer, L=Seoul, S=Seoul, C=KR, with and without the revocation check, and null for the unsigned 2.0.0 Setup. The struct layout, the CRYPT_PROVIDER_CERT offset and the closing call look right to me.
  • Updating from 2.0.0 (unsigned) to the first signed release keeps working: 2.0.0 doesn't have this check and only compares the SHA-256. From the first signed release on, the installed Filee.exe is signed (our release script checks that after a test install), so the check applies to every update after that.
  • It builds on top of main, also together with #408, and UpdateServiceTests pass.

One change before we merge:

  • CheckPublisher runs on the UI thread. After the download, UpdateAsync continues on the dispatcher, and WinVerifyTrust hashes the whole file and, with the revocation check, contacts the CA. Here it took about 0.7 s for a 23 MB signed file, so the ~125 MB Setup would freeze the window for a few seconds, longer on a slow network. Please run it off the UI thread, for example await Task.Run(() => CheckPublisher(installer));. The exceptions still reach the existing catch.

Optional, your call:

  • When the revocation check can't reach the CA (proxy, firewall, offline), WinVerifyTrust fails with CERT_E_REVOCATION_FAILURE, and the log then says the installer "is signed by no publisher Windows trusts", which points people the wrong way. Logging the WinVerifyTrust result would make that clear. Ending at the download page in that case is fine with us.

On our side, we'll keep every Windows release signed with a certificate that has exactly this subject, including when we renew it next year. Thanks!

KnifeLemon and others added 2 commits October 23, 2026 18:07
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cation check

WinVerifyTrust hashes the whole installer and may ask the certificate
authority about revocation, which froze the window for a few seconds on the
~125 MB Setup. Run CheckPublisher on the thread pool; its exceptions still
reach the existing catch.

When the installer's signature only fails the revocation check (offline, a
proxy or a firewall, or a revoked certificate), the log now says so instead of
"signed by no publisher Windows trusts". The installer is still refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@KnifeLemon
KnifeLemon merged commit 32579c5 into KnifeLemon:main Oct 23, 2026
14 of 16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants