Skip to content

ClickOnce: payload files signed independently can leave manifest hashes stale #1065

Description

@bricelam

(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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions