ClickOnce: payload files signed independently can leave manifest hashes stale
- Dominant language
- C#
- Stars
- 581
- Forks
- 116
- Avg merge
- 1d 2h
- Merged PRs (30d)
- 8
Description
(Submitted with Claude Code)
**Describe the bug**
A ClickOnce deployment manifest owns the files beside it: `ClickOnceSigner.SignAsync` reaches into `file.Directory!`, signs the payload files itself, then regenerates the manifest hashes via `mage -update` and signs the manifests.
That ownership isn't reflected in how inputs are selected. If the payload files are *also* matched as top-level inputs -- e.g. `sign code ... "**/*"` over a publish folder -- `Signer.SignAsync` fans out over them with `Parallel.ForEachAsync`, giving each its own temporary directory, and they get signed a second time, concurrently, with no knowledge of the deployment manifest's run.
Two things go wrong:
1. Both runs write the same output path (the payload's own run via `fi.CopyTo(output.FullName, ...)`, the manifest's run via `CopySigningDependencies(fi, output.Directory!, ...)`). Last writer wins, and which one that is isn't deterministic.
2. Each pass produces its own timestamp countersignature, so the two signed copies differ byte for byte. The hashes `mage` baked into the manifest describe the copy the manifest's run produced, which may not be the copy that ships.
The result is a manifest whose hashes don't match the deployed files.
**Repro steps**
1. Publish a ClickOnce application.
2. Run the tool against the publish folder with a pattern that matches both the `.application` and the payload files (e.g. `"**/*"`).
3. Verify the deployed application.
**Expected behavior**
Files owned by a deployment manifest are signed once, as part of their owner, and the manifest's hashes describe the files that actually ship.
**Actual behavior**
Owned payload files are signed twice -- once by their owner, once as inputs of their own -- racing on the output path and leaving the manifest hashes potentially stale.
**Additional context**
This is the same shape as the MSI/external-cabinet problem, where an MSI owns cabinets that can also be matched as inputs and rebuilt independently.
#873 introduces a general mechanism for it: `ISigningDependencyReader` / `ISigningDependencyProvider`, which let a format declare the files it owns. `Signer` then removes owned files from the input list before the fan-out, so they're signed only as part of their owner, and `CopySigningDependencies` becomes a generic copy over the declared set.
That PR only implements a reader for MSI. Migrating ClickOnce onto the same mechanism should fix this, and would let `ClickOnceSigner.CopySigningDependencies` -- currently a hand-rolled sweep of the whole sibling directory -- be replaced by a `GetSigningDependencies` implementation. It's deliberately left out of #873 because it changes a shipping code path and deserves separate review.
Related: #643 and #737 describe workflows that sign ClickOnce files individually, which is the mode that runs into this.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start with ClickOnceSigner.SignAsync, Signer.SignAsync, and ClickOnceSigner.CopySigningDependencies, then read the ISigningDependencyReader and ISigningDependencyProvider mechanism introduced in #873. Migrate ClickOnce to declare its owned payload files so Signer excludes them from independent fan-out and copies the declared set. Done means payload files are signed once and the manifest hashes match the files that ship.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 50/100