Repository navigation
Verify desktop updates against signed file lists and release attestations (#181) - #323
Conversation
…just its programs (#181) Each Windows build lists the SHA-256 of its files that are not executable images in SemanticRouter.Files.dll (deploy/update-file-list.cs write), which vpk signs with the rest of the release. With Updates:Publisher set, the app requires that list to be the publisher's and the package's other files to match it exactly, so a runtimeconfig.json or deps.json changed, added or removed after signing is refused. package-installer.sh checks the packed update against the list before anything is uploaded. Refs #181 Co-Authored-By: Claude <noreply@anthropic.com>
…ublisher is set (#181) Without Updates:Publisher, the desktop app now asks GitHub's attestations API for the downloaded package's SHA-256 and verifies the Sigstore bundle against the trust root it carries: the in-toto statement names the file, the Fulcio certificate was issued to release.yml at the version's tag for a GitHub Actions token, the DSSE signature verifies, and the Rekor entry is bound to it, has a valid signed timestamp, an RFC 9162 inclusion proof and a signed checkpoint. An update that fails is refused and deleted; one that cannot be checked (GitHub unreachable or rate-limited, a log entry the app cannot read) is applied with a tray line saying so. Such updates are downloaded whole, since Velopack re-zips a package rebuilt from deltas. deploy/update-attestation-check.sh (CI and the release's first job) keeps release.yml attesting every update package from a v* tag run, and the embedded trust root equal to Sigstore's.
siebe41
left a comment
There was a problem hiding this comment.
Needs changes: the Sigstore crypto itself is done carefully, but three paths still end in "applied anyway" or "refused for good" when they should not, and the docs promise a transparency-log check that an unsigned field can switch off.
Review at 57442dd (fresh eyes). CI is green. I pushed nothing.
One security finding is held back per SECURITY.md, because it also affects what is already on main. The details are written up for the maintainer outside this thread.
Medium: "could not be checked" applies the update, and an attacker on the network can make that happen
src/SemanticRouter.Desktop/UpdateAttestation.cs:78-85, :98-110, :116-121 and :139 return Unchecked. src/SemanticRouter.Desktop/DesktopUpdater.cs:403-408 then marks the update as verified and applies it on quit. The only sign is a line in the tray and the log.
Scenario: someone holds a stolen token (the #181 attacker) and swaps a *-full.nupkg plus its line in releases.<channel>.json. The victim is on a network the attacker can touch (hotel Wi-Fi, a compromised router). The app lists releases through api.github.com (GithubSource, DesktopUpdater.cs:91) and downloads the package. UpdateAttestation then opens its own new connection to api.github.com (UpdateAttestation.cs:41, :150). The attacker resets that one connection, or presents a bad certificate. That raises HttpRequestException, the result is Unchecked, and the swapped package is applied.
The PR gives two reasons for failing open. Neither holds up:
- "They can't make api.github.com unreachable." That's true only for an attacker who controls nothing but the assets. Add an on-path position and the downgrade costs one dropped connection.
- "Failing closed would only strand offline or rate-limited machines." A machine that gets this far read the release list from
api.hub.lumenfield.workseconds earlier, so it is not offline. What's left is a brief failure, and that can wait for the next check.
There is also a slow, silent failure here. GitHub's answer already carries bundle_url next to the inline bundle (I checked a live response for 0.5.0). If GitHub stops inlining bundles, :116-121 and :139 make every update Unchecked. The gate quietly turns into a warning for every user, and nothing fails upstream.
The same thing happens when the package is missing: DesktopUpdater.cs:396 treats it as Unchecked and applies it, while the publisher branch refuses it (:383).
Fix: on Unchecked, keep the package but don't apply it and don't blacklist the version. Try the check again at the next scheduled check, and have the tray say "waiting to check". Do the same when the package is not found. A lasting Unchecked (Rekor v2, a format change) then shows up as "updates stopped". The maintainer will notice that, unlike a tray line on each user's machine.
Medium: an unsigned field in the bundle switches off every transparency-log check
src/SemanticRouter.Desktop/SigstoreBundle.cs:199-213 skips a log entry in two cases: its logId matches no log the app loaded, or its kindVersion is not dsse 0.0.1. If nothing else is left, :253 returns "unlogged", which becomes VerifiedWithoutLog and then Unchecked (UpdateAttestation.cs:128-130, :137), and the update is applied. Neither field is covered by any signature. SigstoreTrustRoot.cs:65-68 drops every non-ECDSA log. As a result, "a log I can't read (Rekor v2)" and "a log that is not in the trust root at all" look the same.
So a bundle with a valid Fulcio certificate and DSSE signature never needs a real log entry. Changing kindVersion.version to 0.0.2, or putting any 32 bytes in logId, is enough. That removes everything the log is there for: the 10-minute window tied to the certificate, and the public record. One example: any step in a release.yml job with id-token: write (nuget, image-sign, checksums) can get a certificate for release.yml@refs/tags/v<version> during the run. It can then keep the key and sign later, without ever showing up in Rekor. UpdateAttestationTests.cs:192-196 asserts this behaviour as intended.
This undercuts two statements:
docs/desktop.md:65says an applied update "is recorded in Sigstore's transparency log".- The PR body says a bundle "with no transparency-log entry at all" is refused. That is true, but a bundle with a fake entry is applied.
Fix: keep the id of every trust-root log, with a flag for whether the app can verify its key. Then:
- Refuse an entry whose
logIdis in no trust-root log. - In a known ECDSA log, refuse any kind other than
dsse 0.0.1. - Treat as unreadable only an entry in a trust-root log the app cannot verify (the Ed25519 Rekor v2 log).
Per the finding above, don't apply those either.
Medium: when Fulcio rotates its CA, every installed app refuses all later updates
Each app checks with the trust root its own version embeds (SigstoreTrustRoot.cs:36-42). A v0.3 bundle carries only the leaf certificate (SigstoreBundle.cs:72-74), and the chain is built with downloads disabled from that trust root alone (:386-399).
Scenario: Sigstore adds a new Fulcio intermediate or root, and GitHub's certificates start coming from it. Every app released before that cannot build the chain. The update is refused and deleted (UpdateAttestation.cs:138, DesktopUpdater.cs:415-421), with the message "its certificate was not issued by Sigstore's certificate authority", which reads like tampering.
Every later release is refused the same way. _refused is kept in memory only (DesktopUpdater.cs:53, :420), so after each restart the app downloads the whole package again and deletes it again. That also makes docs/desktop.md:67 ("that version is not downloaded again") wrong across restarts. The only way out is a manual reinstall, and nothing tells the user to do that.
update-attestation-check.sh --online helps only apps that install a release carrying the new root before the switch.
Fix: tell "the issuer is not in my trust root" (the leaf's Authority Key Identifier matches no CA certificate the app carries) apart from a chain that is actually bad. In that case, don't delete, don't apply, and don't retry the download each time the app starts. The tray should say this version can no longer check updates and link to the releases page. Later, consider refreshing trusted_root.json from Sigstore's TUF repository.
Low: it is not quite "the way gh attestation verify does", as docs/releases.md:110 says
- The SAN is compared with
OrdinalIgnoreCase(SigstoreBundle.cs:101).ghcompares exactly, andRelease.yml, or a tagV0.6.0, is a different identity. - The repository is pinned by name only. Fulcio's source-repository-ID extension (
1.3.6.1.4.1.57264.1.15) and the API'srepository_idwould pin it against a renamed or deleted account or repository whose name someone registers again. - The predicate type is not checked (
:157-185).ghdefaults tohttps://slsa.dev/provenance/v1. - The leaf's SCT is not checked. The trust root carries
ctlogs, andghrequires one.
Fix: use Ordinal, pin the repository ID, and check the predicate type. Then either verify the SCT (its log key is ECDSA P-256, which the BCL handles) or reword the docs.
Nit: VerifyAsync's doc comment landed on Attested
DesktopUpdater.cs:363-370: the old <summary> for VerifyAsync now sits above Attested, followed by a second <summary>. Move it back down.
What's fine
- Signed entry timestamp: the canonical JSON (body, integratedTime, hex logID, global logIndex) matches Rekor, and the real 0.5.0 bundle proves it.
- Inclusion proof: the RFC 9162 code follows the spec step by step, and is tested on every leaf of trees of size 1 to 17.
- Checkpoint: the signed-note split, the size and root comparison and the key-hint check are correct.
- Log body: it is bound to the payload hash, the signature and the exact certificate.
- Times: the logged time must be inside the certificate's validity and the log key's period.
- Chain: built with
CustomRootTrustat the logged time. All 1475 tests passed on macOS, and Linux and Windows were green too, so the platform chain builders accept both the real Fulcio chain and the test CA. - Deltas: dropping them under the check is the right call, and it is tested.
- Tests: none uses the network. The 0.5.0 fixture is a real API answer.
- Bookkeeping: the CHANGELOG line is under Unreleased / Security. No library's public API changes.
…and the release checks that the list of files is signed The list of files must carry the installer's signature before a release is uploaded, or every desktop app with a publisher policy would refuse it. Co-Authored-By: Claude <noreply@anthropic.com>
…e/P07b-update-attestation # Conflicts: # CHANGELOG.md
…, not applied; unsigned log fields can't skip the log; an unknown Sigstore authority is told apart - Unchecked (API unreachable, no bundle, an entry in a log the app cannot verify, package missing): the package is kept, not applied, and checked again at the next check. - A log entry whose logId is in no trust-root log, or whose kind is not dsse 0.0.1 in a log the app checks, is refused; only an entry in Rekor v2's log counts as unreadable. - A certificate whose authority key identifier names no trust-root certificate is Outdated: kept, not applied, not offered again this run, and the tray says to install the new version by hand. - Identity compared Ordinal, the repository id (Fulcio 1.3.6.1.4.1.57264.1.15) pinned, the predicate type must be SLSA provenance v1. - doctor: no more 'or GitHub cannot be asked'; VerifyAsync's doc comment moved back.
…og entries, the repository id and predicate type; 'much as gh', without the SCT
|
Review of 57442dd addressed. Branch merged with its base first (already up to date with #319's fixes, da24698).
Verified: the attestation and update tests pass (52/52), and 4 of 4 mutations of the new checks fail them (case-insensitive identity, unknown log treated as unreadable, unknown CA never reported, unchecked update applied). |
Desktop update packages are now checked before they can be applied: Windows machines with an Updates:Publisher policy require the publisher's signed data-file list, and GitHub updates without a publisher policy require release-workflow Sigstore provenance. Failed checks refuse the update; checks that cannot complete hold it for retry or manual upgrade.
This PR combines the complete changes from #319 and #323 and targets main. It includes the required release signature check for SemanticRouter.Files.dll, real and synthetic attestation fixtures, update-control tests, release-workflow validation, trust-root freshness checks, documentation, and security changelog entries. It also fixes inherited retention-test fixtures for the current AuditEntry API and restores unrelated changelog history.
Validation passed on commit 9fac100: CI, CodeQL, Pages, Egress hosts and Gateway benchmark are all green. CI includes the full Windows/Linux/macOS test suites, dashboard end-to-end tests, installer update tests, installer SBOMs, container checks, dependency auditing, formatting, release checks, attestation validation and package checks. The earlier Linux bounded-time test passed unchanged on this run.
SDK setup uses 10.0.402 directly with patch roll-forward instead of resolving an unavailable 10.0.403 channel download. Contributor instructions match the new baseline. The generated SemanticRouter.Files library is classified as a first-party SBOM component with the repository's licence, and Windows installer SBOM validation requires it.
Known limitations: default publisher verification still waits for signing certificates; Ed25519/Rekor v2 entries and bundle_url-only responses hold updates; SCT verification and delta downloads under attestation verification are not implemented. Prior reviews also reference privately reported security findings affecting main; their details are not available in these public PRs.
Refs #181. Supersedes #319.