Skip to content

fix: exit codes, cmd expansion, signed self-update, import arguments - #31

Merged
FedeB2160 merged 3 commits into
mainfrom
fix/d1-d4-correctness-security
Oct 28, 2026
Merged

FedeB2160 merged 3 commits into
mainfrom
fix/d1-d4-correctness-security

Conversation

@FedeB2160

Copy link
Copy Markdown
Owner

What and why

Four defects found while comparing WinGet Studio with UniGetUI: one wrong result shown to the user, and three ways an app that always runs elevated (ADR 0002) could be made to run something it should not.

  • Install and update results (D1). The exit codes that count as a warning were off by one: a failed installation (0x8A15010A, restart and try again) or a cancelled one (0x8A15010C) showed as a warning and was skipped by Retry failed, while already installed (0x8A15010D) and the PC will restart (0x8A15010B) showed as failures. Names checked with winget error on winget 1.29.380.
  • % in a winget command line (D2). cmd expands %VAR% after the quote guard of ADR 0004, even inside quotes. Any argument line with % is now refused, with the reason in the log, and cmd runs with /v:off, so a DelayedExpansion value set in HKCU by a non-elevated process cannot expand !VAR! either.
  • Self-update (D3, new ADR 0022).
    • The download must be signed with the WinGet Studio key, in every channel. The SHA-256 digest comes from GitHub with the file, so on its own it does not survive a compromised release.
    • The pin is the SHA-256 of the public key of assets\WinGetStudio-codesign.cer, not the certificate thumbprint, so renewing the certificate with the same key before 2031 needs no bridge release.
    • The check uses WinVerifyTrust (accepting 0 or CERT_E_UNTRUSTEDROOT), not Get-AuthenticodeSignature's Status: altering bytes of a real signature produced hundreds of broken variants that PowerShell reports as UnknownError with our certificate, exactly like the good file.
    • The download waits in %SystemRoot%\Temp\wgt_update_<guid>, with an ACL for Administrators and SYSTEM only, instead of the user's %TEMP%, where a non-elevated process could swap it between check and run.
    • When the signature does not match, a dialog says the update was not installed and links to the latest release.
  • Import (D4). By default the imported file keeps only PackageIdentifier, Version, Channel and Scope, even with every package ticked; the --custom/--override arguments winget saved are passed again only if the user ticks a new box under the preview. An import file can come from anyone.

Also in DEVELOPMENT.md: the procedure to renew the certificate with the same key, rehearsed on a throwaway key of the same kind (the rehearsal found that X509Store.Add drops the private key, so the renewed certificate goes in through a .pfx).

From now on every release must be signed with the published key: an unsigned build, or one signed with another key, is refused by every copy that has this change. Copies up to v1.12.0 do not check signatures and are not affected.

How it was tested

  • Test-Ui.ps1 passes (offline), including one run with a signed dist\WinGetStudio.exe, where the test also alters bytes of its signature and checks they are refused
  • Test-InvokeWinGet.ps1 passes: one assertion per exit code, % refused on both paths, /v:off
  • Test-Ui.ps1 -Live: not run. This touches the cmd line used for writes; worth running before the release
  • Tried in the running app. Not done: the self-update end to end (it needs a release newer than the copy under test), the elevated creation of the download folder in %SystemRoot%\Temp and its sweep (tests run unelevated, on %TEMP%), and a visual check of the new box in the import preview
  • Done by hand: WinVerifyTrust on the real signed exe and setup (accepted), on cmd.exe and a text file (refused), and on about 1,000 single-byte alterations of the signature (none of the broken ones accepted)

Checklist

  • One line under ## Unreleased in CHANGELOG.md (and the existing import line corrected)
  • README / DEVELOPMENT.md lines this change makes wrong are updated
  • A new ADR in docs/adr/ if this is an architectural decision (ADR 0022; notes in 0004, 0012, 0013, 0016)
  • Works on Windows PowerShell 5.1 (no PowerShell 6+ syntax)
  • No certificates, keys or other secrets in the diff (only the public key's hash)

D1: the benign exit-code list in Get-UpdateStatus was off by one.
Warnings are now 0x8A150109, 0x8A15010B, 0x8A15010D, 3010 and 1641
(names checked with winget error 1.29.380); a failed or cancelled
install is an error again. One assertion per code.

D2: Invoke-WinGet refuses any argument line containing '%', which cmd
expands after the quote check, and runs cmd with /v:off so a
DelayedExpansion value in HKCU cannot expand !VAR!.

D3: the self-update also checks the Authenticode signer against the
thumbprint of assets\WinGetStudio-codesign.cer (Valid or UnknownError),
in every channel, and downloads to %SystemRoot%\Temp\wgt_update_<guid>
with an Administrators/SYSTEM-only ACL. The portable exe is copied into
place so it keeps the destination ACL. ADR 0022.

D4: import keeps only PackageIdentifier, Version, Channel and Scope by
default, even with every package ticked; an unticked box in the preview
keeps the saved --custom/--override arguments.
Altering single bytes inside the signature of a signed release showed
hundreds of broken variants that Get-AuthenticodeSignature reports as
UnknownError with our certificate as signer, like the good file, and
that the previous check accepted. Test-UpdateSignature now takes the
WinVerifyTrust result (Get-UpdateSignerTrust, a P/Invoke compiled on
first use) and accepts only 0 or CERT_E_UNTRUSTEDROOT.

The pin is the SHA-256 of the public key of
assets\WinGetStudio-codesign.cer instead of the certificate thumbprint,
so renewing the certificate with the same key before 2031 needs no
bridge release. When the signature does not match, a dialog says the
update was not installed and links to the latest release.

Test-Ui finds the WgtRow type by name, now that it is not the only
Add-Type -TypeDefinition. ADR 0022, ADR 0012 and DEVELOPMENT.md
(renewing the certificate) updated.
Rehearsed in Windows PowerShell 5.1 on a key of the same kind as the
real one (CNG, Software KSP, exportable): the renewed certificate has a
new thumbprint and the same public key, and the pinned check accepts
exes signed before and after the renewal.

The rehearsal found two gaps in the documented snippet: X509Store.Add
stores the certificate without the link to its private key, so it now
goes into the store through a .pfx export and import (which is also the
backup), and the key usage and subject key identifier extensions of the
current certificate were missing.
@FedeB2160
FedeB2160 merged commit e6bf305 into main Oct 28, 2026
2 checks passed
@FedeB2160
FedeB2160 deleted the fix/d1-d4-correctness-security branch October 28, 2026 16:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant