Skip to content

Fix Windows TRUST_E_BAD_DIGEST on portable-signed MSIX/AppX; sign plain-ZIP packages - #46

Merged
Marc-André Moreau (mamoreau-devolutions) merged 3 commits into
masterfrom
claude/fix-msix-zip64-eocd-531450
Nov 2, 2026
Merged

Marc-André Moreau (mamoreau-devolutions) merged 3 commits into
masterfrom
claude/fix-msix-zip64-eocd-531450

Conversation

@mamoreau-devolutions

@mamoreau-devolutions Marc-André Moreau (mamoreau-devolutions) commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Problem

psign-tool --mode portable sign produced MSIX/AppX packages that psign's own trust-verify-msix accepted, but that Windows rejected:

  • signtool verify /pa → 0x80096010 (TRUST_E_BAD_DIGEST)
  • Get-AuthenticodeSignature → HashMismatch

The same unsigned package signed by signtool.exe with the same self-signed PFX only fails on the untrusted root.

Root cause

The staged package and the final package are both rebuilt with the zip 0.6 crate. Its layout differs from AppxPackaging (makeappx/signtool) in three ways, and Windows AppxSip rejects a package that differs in any of them, even when the portable AXCD digest is self-consistent:

Field zip crate AppxPackaging
End records classic EOCD only ZIP64 EOCD (v45) + locator + all-0xFF classic EOCD
CD "version made by" 0x032e (Unix, 4.6) 0x002d (45)
CD external attributes 0x81a40000 (0o100644) 0

I tested this on Windows 11 / SDK 10.0.26100. Each row was toggled on its own, and the only combination Windows accepted was all three AppxPackaging values. I also tried version made by 20 / 0x0314 / 46 and external attrs 0x20 (FILE_ATTRIBUTE_ARCHIVE): Windows rejected all of them. Hashing synthesized AppxPackaging values over a non-conforming archive doesn't match Windows either, so Windows isn't normalizing these fields. It requires them.

Changes

  • psign-sip-digest: new msix_digest::normalize_zip_for_appx_signing rewrites a ZIP into the AppxPackaging layout. It sets "version made by" to 45 and external attrs to 0 on every CD entry, and replaces the end records with ZIP64 EOCD + locator + sentinel classic EOCD. Local headers and data stay untouched, so entry offsets don't move.
  • psign-portable-core: the flat and bundle staging (stage_flat_msix_for_signature, stage_msix_bundle_for_signature) and replace_msix_signature_part pass their output through it. The digest is computed over the same layout that gets shipped.
  • Portable verify (hash_cd_and_eocd): rejects packages Windows rejects, meaning those with no ZIP64 EOCD or with non-AppxPackaging "version made by" / external attributes. Packages written by psign ≤ 0.8.1 now fail trust-verify-msix with an explicit message instead of passing.
  • Block map: parses and validates the SDK 26100+ b4:FileHash element (http://schemas.microsoft.com/appx/2021/blockmap), which holds the digest of the whole uncompressed file. Before this, verify-msix failed on modern signtool output with unexpected AppxBlockMap.xml element `FileHash` .
  • Docs: docs/rust-sip-gaps.md.

Signing packages not built by makeappx (pinget source2.msix)

Pinget is dropping makeappx, so its input to psign will be a plain ZIP of the package layout. With only the layout fix above, signing such packages still failed or lost compression:

  • [Content_Types].xml from generic tools broke signing (content type for `AppxBlockMap.xml` is `application/vnd.ms-appx.manifest+xml` ), and a ZIP with no [Content_Types].xml was refused.
  • psign stored every payload uncompressed, so a package makeappx would write at ~145 KB came out around 1.4 MB.

Changes:

  • psign-portable-core::appx_zip: a small ZIP writer that emits the AppxPackaging layout directly. It replaces the zip-crate writer for staging and for the final AppxSignature.p7x embed, which now copies the other entries' compressed bytes verbatim. Payloads are deflated as makeappx does: a full flush after every 64 KiB block, so each block decodes independently, followed by an empty final block, and each block's compressed size goes into Block/@Size. PNG/JPEG/ZIP/package/media payloads, payloads that compression wouldn't shrink, and bundle children are stored.
  • msix_digest::appx_content_types_for_signing: merges or generates [Content_Types].xml. It keeps the input's types, adds makeappx-style Defaults for payload extensions, forces the (bundle) manifest type, and adds the block map and signature Overrides.
  • Docs: docs/linux-signing-pipelines.md (MSIX without makeappx) and docs/rust-sip-gaps.md.

Checked on Windows 11 / SDK 26100 with a pinget-shaped package (neutral windows.appExtension manifest, a 1.3 MB Public/index.db, PNG assets), signed on Windows and on Linux (WSL Ubuntu 24.04), with and without RFC 3161 timestamping (DigiCert TSA):

Input Signed size WinVerifyTrust Get-AuthenticodeSignature makeappx unpack trust-verify-msix
generic deflated ZIP + [Content_Types].xml 148 KB 0x800B0109 UnknownError ok ok
plain ZIP, no [Content_Types].xml 148 KB 0x800B0109 UnknownError ok ok
makeappx-built pinget layout (145 KB unsigned) 148 KB 0x800B0109 UnknownError ok ok
makeappx-built minimal package with a PE 42 KB 0x800B0109 UnknownError ok ok

0x800B0109 means only the self-signed root is untrusted. Timestamped signatures show The signature is timestamped in signtool verify /pa /v.

Tests

  • tests/msix_native_makeappx.rs::portable_signed_makeappx_msix_fails_windows_verify_only_on_untrusted_root (Windows CI, existing "Portable MSIX/AppX native makeappx.exe validation" step):
    • builds an MSIX with makeappx pack;
    • creates a throwaway self-signed code-signing PFX in memory with CertificateRequest (never imported into any store);
    • portable-signs it with SHA256 and SHA384;
    • asserts that WinVerifyTrust (psign-tool verify /pa) reports 0x800B0109, never 0x80096010, that Get-AuthenticodeSignature reports UnknownError (not HashMismatch), and that trust-verify-msix passes.
  • psign-portable-core::staged_and_signed_msix_use_appx_zip64_layout: staged and signed .msix / .appx / .msixbundle outputs have ZIP64 end records and normalized CD entries, checked by an independent byte parser.
  • The Windows test also signs a plain ZIP of the same layout (no block map, no content types) and requires makeappx unpack to accept every signed output.
  • psign-portable-core::signs_plain_zip_package_without_makeappx_metadata: a plain deflated ZIP with no [Content_Types].xml signs into a compressed package with per-block Size attributes and generated content types.
  • appx_zip unit tests: each deflated block decodes independently, incompressible/precompressed data is stored, and output round-trips through the zip reader. msix_digest::content_types_for_signing_fill_gaps_and_keep_existing_types covers the content-types builder.
  • psign-sip-digest: unit tests for the normalizer (layout, idempotence, unchanged offsets), for the verify-side rejections, and for FileHash parse/validate (match, mismatch, wrong length, duplicate).

Verification (Windows 11, SDK 10.0.26100)

makeappx-built MSIX, portable-signed by this branch with a throwaway self-signed PFX:

signtool verify /pa  -> SignTool Error: A certificate chain processed, but terminated in a root
                        certificate which is not trusted by the trust provider.
psign-tool verify /pa (WinVerifyTrust) -> 0x800B0109   (was 0x80096010 before this change)
Get-AuthenticodeSignature -> UnknownError              (was HashMismatch)
psign-tool portable trust-verify-msix -> ok

The portable-signed .msixbundle (with a portable-signed child) also fails only on the untrusted root. All committed signed MSIX/AppX fixtures still pass verify-msix.

cargo fmt --all, cargo clippy --workspace --all-targets --locked: clean. cargo test --workspace --locked --no-fail-fast: all MSIX tests pass. Locally, 5 corpus_sign_verify tests fail with 0x800B0109 on PE/ZIP-authenticode fixtures because this machine does not trust the Devolutions test root (CI bootstraps it via bootstrap-devolutions-authenticode.ps1). Those failures are unrelated to this change.

🤖 Generated with Claude Code

Windows AppxSip reported TRUST_E_BAD_DIGEST (0x80096010) for MSIX/AppX
packages produced by `psign-tool --mode portable sign`, while psign's own
verify-msix accepted them. The staged and final packages were written by
the zip crate, whose layout differs from makeappx/signtool in three ways,
each of which alone makes Windows reject the digest:

- only a classic EOCD (no ZIP64 EOCD record + locator);
- central directory "version made by" 0x032e instead of 45;
- external attributes 0x81a40000 instead of 0.

Add msix_digest::normalize_zip_for_appx_signing and pass the staged flat
and bundle packages and the final AppxSignature.p7x embed through it.
Make the portable AXCD digest reject layouts Windows rejects, so psign
verify no longer passes packages that fail natively. Parse and validate
the SDK 26100 `b4:FileHash` block-map element, which previously made
verify-msix fail on modern signtool output.

Add a Windows test that portable-signs a makeappx-built package with a
throwaway in-memory PFX and asserts WinVerifyTrust fails only with
CERT_E_UNTRUSTEDROOT, plus portable layout and FileHash unit tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GitHub Actions runs cargo under pwsh 7, whose inherited PSModulePath makes
Windows PowerShell 5.1 fail to autoload Microsoft.PowerShell.Security
(Get-AuthenticodeSignature: CouldNotAutoloadMatchingModule). Drop the
inherited PSModulePath and import the module from $PSHOME explicitly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Publishers that drop makeappx (pinget's source2.msix) hand psign a plain
ZIP of the package layout. Signing those failed when [Content_Types].xml
came from a generic tool (no AppxBlockMap.xml override) or was missing,
and every payload was stored uncompressed (~10x larger than makeappx).

- appx_zip: write packages in the AppxPackaging layout directly, deflating
  payloads with a full flush per 64 KiB block (each block decodes on its
  own) and listing compressed block sizes in AppxBlockMap.xml. Already
  compressed formats, incompressible data, and bundle children stay
  stored. The final p7x embed copies other entries' compressed bytes.
- msix_digest::appx_content_types_for_signing: merge or generate
  [Content_Types].xml with makeappx-style defaults, the manifest type,
  and the block map / signature overrides.

Tested on Windows 11 / SDK 26100 with pinget-shaped packages signed on
Windows and Linux, with and without RFC 3161 timestamps: WinVerifyTrust
fails only with CERT_E_UNTRUSTEDROOT and makeappx unpack accepts them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mamoreau-devolutions Marc-André Moreau (mamoreau-devolutions) changed the title Fix Windows TRUST_E_BAD_DIGEST on portable-signed MSIX/AppX Fix Windows TRUST_E_BAD_DIGEST on portable-signed MSIX/AppX; sign plain-ZIP packages Nov 2, 2026
@mamoreau-devolutions
Marc-André Moreau (mamoreau-devolutions) merged commit 157cf1a into master Nov 2, 2026
36 checks passed
@mamoreau-devolutions
Marc-André Moreau (mamoreau-devolutions) deleted the claude/fix-msix-zip64-eocd-531450 branch November 2, 2026 21:42
Marc-André Moreau (mamoreau-devolutions) added a commit that referenced this pull request Nov 2, 2026
## Summary
- Bump all workspace packages and `Cargo.lock` from 0.8.1 to 0.8.2 using
`scripts/bump-version.ps1`.
- Synchronize the PowerShell module, NuGet tool fallback version, README
packaging example, and release workflow example.
- Prepare version metadata only. This PR does not dispatch the release
workflow or publish packages.

**Merge after #46.** 0.8.2 exists to ship that fix:
portable-signed MSIX/AppX packages that Windows rejected with
`TRUST_E_BAD_DIGEST`, plus signing of plain-ZIP packages for pinget's
`source2.msix`. The two PRs merge cleanly in either order, and the
combined `Cargo.lock` passes `cargo metadata --locked`.

## Validation
- `pwsh ./scripts/bump-version.ps1 -Version 0.8.2 -Check`
- `cargo metadata --locked --no-deps --format-version 1`
- `git diff --check`
- Test merge with `claude/fix-msix-zip64-eocd-531450`: clean, and
`-Check` / `--locked` still pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Marc-André Moreau (mamoreau-devolutions) added a commit to Devolutions/jiffshot that referenced this pull request Nov 2, 2026
## Summary
- Update the Windows signing action to psign 0.8.2 and pin the published
Windows x64 archive's SHA-256.
- Include Devolutions/psign#46, which fixes the Windows hash mismatch
seen when verifying portable-signed MSIX packages in the failed release
job.

## Verification
- Verified the downloaded release archive's SHA-256 and executable
version.
- A new release workflow run after merging is needed to validate Azure
Trusted Signing end to end; rerunning the failed job would use its
original revision.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Marc-André Moreau (mamoreau-devolutions) added a commit to Devolutions/pinget that referenced this pull request Nov 3, 2026
…tool (#60)

## Summary
Follow-up to #59. The WinGet source publishers no longer need the
Windows SDK (`makeappx.exe`, `signtool.exe`) or, in Rust, PowerShell.

- **Packaging:** both publishers write `source2.msix` in-process, in the
exact AppxPackaging (makeappx) ZIP layout:
  - stored entries;
- central directory entries with version-made-by 45 and zero external
attributes;
  - ZIP64 end records;
  - an SHA-256 block map with a hash per 64 KiB block;
  - content types.

Windows AppxSip rejects signatures over any other layout with
`TRUST_E_BAD_DIGEST`. The Rust (`msix.rs`) and .NET
(`MsixPackageWriter.cs`) writers are byte-identical for the same
payload, and a .NET test pins hashes produced by the Rust writer.
- **Signing:** done with the cross-platform
[psign-tool](https://github.com/Devolutions/psign)
(`Devolutions.Psign.Tool`, **0.8.2 or later**; 0.8.1's portable MSIX
signatures fail Windows verification, fixed in Devolutions/psign#46).
- All arguments, including the PFX password, go through a private,
short-lived response file, so the password never appears on a command
line. That matters on Linux, where `/proc/*/cmdline` is readable by
other users, and for the `.cmd` launcher `dotnet tool install` creates
on Windows.
- **Verification**, after signing and in `verify --destination`:
  - the package digests;
- the signer subject matches `--publisher`, formatted the way Windows
formats certificate subjects;
  - the signer has the Code Signing EKU and is currently valid;
- chain trust: the Windows store on Windows (as `signtool verify /pa`
did), or new repeatable `--trusted-ca` anchors.
- **CLI:** `--makeappx`/`--signtool` become `--psign-tool`; `doctor`
JSON reports `psignToolPath`.
- **Portability:** `publish` and `verify` now run off Windows, using the
existing portable MSZIP decoders. `build` still compresses with the
Windows Compression API.
- **CI, scripts and docs:**
  - CI installs psign-tool 0.8.2.
  - The gate scripts take `-PsignToolPath`.
  - The interoperability check no longer needs makeappx.
  - Docs are updated.

## Validation (local, Windows 11, SDK 10.0.26100)
- **Rust:** publisher tests pass (19 unit + 26 conformance), as do the
CLI tests; `clippy` and `fmt` are clean.
- **.NET:** the solution builds; 80/80 publisher tests pass.
- **Interop:** `tests/publisher/run-production-conformance.ps1` passes:
Rust and .NET reopen each other's state and verify each other's unsigned
feeds.
- **Signed end to end** with released psign-tool 0.8.2, both CLIs, via
the plain exe and the dotnet-tool `.cmd` launcher, with a password
containing quotes, `@` and `%PATH%`:
- `signtool verify /pa` and `Get-AuthenticodeSignature` report only the
untrusted throwaway root, never a bad digest;
  - `--trusted-ca` publish and verify succeed;
  - a mismatched `--publisher` is rejected;
  - `makeappx unpack` accepts both signed and unsigned output.
- **Not yet run:** the signed stock-WinGet acceptance job, which runs in
CI on this PR. Note that NuGet had not yet listed the 0.8.2 platform
packages (`Devolutions.Psign.Tool.win-x64`, …) shortly after the
release. If they're still missing, the job's install step will fail
until NuGet finishes indexing.

## Notes
- The full Rust and .NET `source2.msix` still differ, because their
manifest text and generated logos differ (unchanged from before). Only
the container layout is identical.
- `tests/publisher/build_v2_fixture.ps1` still uses makeappx on purpose.
It's a manual, independent reference-fixture builder, and nothing calls
it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant