Skip to content

Verify desktop updates against signed file lists and release attestations (#181) - #323

Merged
siebe41 merged 17 commits into
mainfrom
sprint/release/P07b-update-attestation
Oct 25, 2026
Merged

siebe41 merged 17 commits into
mainfrom
sprint/release/P07b-update-attestation

Conversation

@siebe41

@siebe41 siebe41 commented Oct 21, 2026 •

Copy link
Copy Markdown
Owner

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.

siebe41 and others added 2 commits October 21, 2026 02:05
…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 siebe41 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.github.com seconds 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:65 says 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 logId is 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). gh compares exactly, and Release.yml, or a tag V0.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's repository_id would pin it against a renamed or deleted account or repository whose name someone registers again.
  • The predicate type is not checked (:157-185). gh defaults to https://slsa.dev/provenance/v1.
  • The leaf's SCT is not checked. The trust root carries ctlogs, and gh requires 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 CustomRootTrust at 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.

siebe41 and others added 3 commits October 22, 2026 17:06
…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
@siebe41

siebe41 commented Oct 22, 2026

Copy link
Copy Markdown
Owner Author

Review of 57442dd addressed. Branch merged with its base first (already up to date with #319's fixes, da24698).

  1. Medium: "could not be checked" applies the update. Fixed in 29f02ed. Unchecked no longer applies anything: the package is kept (not deleted, not blacklisted), Ready doesn't report it, the tray says "Update X is waiting to be checked against its attestation: …", and the next check (or Restart to update) checks it again. Velopack finds the kept package on disk ("Package already exists on disk"), so a retry doesn't download it again. A missing package is held the same way. An answer with only bundle_url is held too (the app still doesn't fetch it). Tests: API down → held, then applied once the API answers; package missing → held. doctor's line lost "or GitHub cannot be asked". Decision 2 in the body is rewritten.
  2. Medium: an unsigned field switches off the log check. Fixed in 29f02ed. SigstoreTrustRoot keeps the id of every trust-root log (Unreadable: the ones whose key the app can't check, i.e. Rekor v2). An entry whose logId is in no trust-root log is refused, and a kind other than dsse 0.0.1 in a log the app checks is refused. Only an entry in an unreadable trust-root log is "unlogged", and per 1 that's held, not applied. The test that asserted the old behaviour now asserts refusal (0.0.2 kind, 32 zero bytes as logId, Rekor v1 missing from the trust root). docs/desktop.md's "recorded in Sigstore's transparency log" is true again.
  3. Medium: a Fulcio CA rotation refuses every later update. Fixed in 29f02ed. When the leaf's authority key identifier names no certificate in the trust root, the bundle is UnknownAuthority, and the update is Outdated: kept, not applied, not offered again in this run. The tray says "Update X cannot be checked by this version of the app: … Install the new version yourself from https://github.com/siebe41/SemanticRouter.NET/releases". A certificate from another CA that names the trusted CA is still refused as a bad chain (test). Since the package is kept, a later start doesn't download it again. docs/desktop.md's "not downloaded again" now says "while the app runs". Decision 9 in the body covers the trade-off: someone with their own CA gets this message instead of "refused", but still nothing is applied. Not done: refreshing the trust root from Sigstore's TUF repository.
  4. Low: not quite gh attestation verify. SAN compared Ordinal, the repository id pinned (Fulcio 1.3.6.1.4.1.57264.1.15 = 1409353960, ProjectLinks.RepositoryId), and the predicate type must be https://slsa.dev/provenance/v1: all in 29f02ed, each with a test (Siebe41/…, id 42, a missing id, another predicate type). The SCT is not verified. Instead the docs and the code comment now say "much as gh attestation verify" and name the gap (12105fa), and it's in the body's Not done.
  5. Nit: VerifyAsync's doc comment. Fixed in 29f02ed: it's back above VerifyAsync, rewritten for the new behaviour.
  6. The held-back finding. Not changed here: held for a private report, as the review asked, since it also concerns main.

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). dotnet build -c Release gives 0 warnings, dotnet format --verify-no-changes is clean, and the full suite is 1476/1477 locally (the 1 is the known OPENAI_API_KEY test-isolation failure on this desktop). CI is green on 12105fa.

@siebe41 siebe41 changed the title Check an update from GitHub against its release attestation when no publisher is set (#181) Verify desktop updates against signed file lists and release attestations (#181) Oct 25, 2026
@siebe41
siebe41 changed the base branch from sprint/release/P07-update-integrity to main October 25, 2026 19:00
@siebe41
siebe41 marked this pull request as ready for review October 25, 2026 19:16
@siebe41
siebe41 merged commit 6b9fcea into main Oct 25, 2026
18 checks passed
@siebe41
siebe41 deleted the sprint/release/P07b-update-attestation branch October 25, 2026 22:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

usage-sprint Opened by the Oct 2026 usage sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant