Skip to content

Commit da4f34f

Browse files
Replace makeappx and signtool with in-process MSIX writing and psign-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>
1 parent 0757005 commit da4f34f

23 files changed

Lines changed: 2317 additions & 678 deletions

‎.github/workflows/ci.yml‎

Lines changed: 12 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -121,17 +121,9 @@ jobs:
121121
run: |
122122
cargo build --manifest-path rust/Cargo.toml -p pinget-cli --release
123123
if ($LASTEXITCODE -ne 0) { throw 'Rust publisher CLI build failed.' }
124-
$sdkRoot = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin'
125-
$makeAppx = Get-ChildItem -LiteralPath $sdkRoot -Directory |
126-
Sort-Object Name -Descending |
127-
ForEach-Object { Join-Path $_.FullName 'x64\makeappx.exe' } |
128-
Where-Object { Test-Path -LiteralPath $_ } |
129-
Select-Object -First 1
130-
if (-not $makeAppx) { throw 'Windows SDK MakeAppx was not found.' }
131124
& .\tests\publisher\run-production-conformance.ps1 `
132125
-RustCli .\rust\target\release\pinget.exe `
133-
-DotnetAssembly .\dotnet\src\Devolutions.Pinget.Cli\bin\Release\net10.0\pinget.dll `
134-
-MakeAppxPath $makeAppx
126+
-DotnetAssembly .\dotnet\src\Devolutions.Pinget.Cli\bin\Release\net10.0\pinget.dll
135127
if ($LASTEXITCODE -ne 0) { throw 'Publisher interoperability checks failed.' }
136128
137129
- name: Build native COM DLLs
@@ -193,30 +185,22 @@ jobs:
193185
-OutputRoot artifacts/ci-stock-winget-packages
194186
if ($LASTEXITCODE -ne 0) { throw 'Building the disposable x64 publisher CLIs failed.' }
195187
196-
- name: Run signed stock WinGet acceptance with runner-local credentials
188+
- name: Install psign-tool
197189
shell: pwsh
198190
run: |
199-
$sdkRoot = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin'
200-
$sdkTools = Get-ChildItem -LiteralPath $sdkRoot -Directory |
201-
Sort-Object Name -Descending |
202-
ForEach-Object {
203-
[pscustomobject]@{
204-
MakeAppx = Join-Path $_.FullName 'x64\makeappx.exe'
205-
SignTool = Join-Path $_.FullName 'x64\signtool.exe'
206-
}
207-
} |
208-
Where-Object {
209-
(Test-Path -LiteralPath $_.MakeAppx) -and
210-
(Test-Path -LiteralPath $_.SignTool)
211-
} |
212-
Select-Object -First 1
213-
if (-not $sdkTools) { throw 'Windows SDK MakeAppx and SignTool were not found.' }
191+
# 0.8.2 is the first release whose portable MSIX signatures verify on Windows.
192+
dotnet tool install Devolutions.Psign.Tool --version $env:PSIGN_VERSION --tool-path artifacts/psign
193+
if ($LASTEXITCODE -ne 0) { throw 'psign-tool installation failed.' }
194+
env:
195+
PSIGN_VERSION: 0.8.2
214196

197+
- name: Run signed stock WinGet acceptance with runner-local credentials
198+
shell: pwsh
199+
run: |
215200
pwsh -NoLogo -NoProfile -File (Resolve-Path 'tests/publisher/run-ephemeral-host-publisher-gate.ps1') `
216201
-RustCliPath (Resolve-Path 'artifacts/ci-stock-winget/rust/win-x64/pinget.exe') `
217202
-DotnetCliPath (Resolve-Path 'artifacts/ci-stock-winget/dotnet-nativeaot/win-x64/pinget.exe') `
218-
-MakeAppxPath $sdkTools.MakeAppx `
219-
-SignToolPath $sdkTools.SignTool
203+
-PsignToolPath (Resolve-Path 'artifacts/psign/psign-tool.cmd')
220204
if ($LASTEXITCODE -ne 0) { throw 'Signed stock WinGet acceptance failed.' }
221205
222206
- name: Remove disposable build outputs
@@ -225,3 +209,4 @@ jobs:
225209
run: |
226210
Remove-Item -LiteralPath 'artifacts/ci-stock-winget' -Recurse -Force -ErrorAction SilentlyContinue
227211
Remove-Item -LiteralPath 'artifacts/ci-stock-winget-packages' -Recurse -Force -ErrorAction SilentlyContinue
212+
Remove-Item -LiteralPath 'artifacts/psign' -Recurse -Force -ErrorAction SilentlyContinue

‎docs/repository-publishing.md‎

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,22 @@ URLs; the build does not download or execute them.
1919
sets (version, installer, default locale and optional additional locales).
2020
- Separate directories for inputs, publisher state and published files.
2121
Do not put one inside another or use symlinks/reparse points in them.
22-
- For publication on Windows, the Windows SDK's `makeappx.exe` and
23-
`signtool.exe`. `repository doctor` reports tool availability; explicit tool
24-
paths can be supplied when they are not discoverable.
22+
- For signed publication, [`psign-tool`](https://github.com/Devolutions/psign)
23+
**0.8.2 or later**, a cross-platform Authenticode signer: `dotnet tool
24+
install -g Devolutions.Psign.Tool`. Earlier versions produce MSIX signatures
25+
that Windows rejects. `repository doctor` reports whether it was found on
26+
`PATH` or in the .NET global tool directory; `--psign-tool` supplies an
27+
explicit path. The Windows SDK (`makeappx.exe`, `signtool.exe`) is not used:
28+
the publisher writes the MSIX container itself.
2529
- A code-signing certificate with its private key in a PFX, and a publisher
26-
distinguished name matching that certificate.
30+
distinguished name matching that certificate's subject.
2731

28-
Building, verification and publication currently require Windows because
29-
sidecars use the native Windows Compression API. `status` and `diff` can
30-
inspect state on other platforms; `doctor` reports the missing capability.
31-
Do not infer publishing support from the availability of the CLI on a platform.
32-
Unsupported toolchains fail rather than silently produce unsigned output.
32+
Building requires Windows because sidecars are compressed with the native
33+
Windows Compression API. `publish`, `verify`, `status` and `diff` also run on
34+
Linux and macOS, so a snapshot built on Windows can be signed and published
35+
elsewhere. Do not infer publishing support from the availability of the CLI on
36+
a platform. A missing signing tool fails rather than silently producing
37+
unsigned output.
3338

3439
## Build, inspect and publish
3540

@@ -92,12 +97,19 @@ For local format development only, replace the signing options with
9297
publisher does not install certificates, configure WinGet source trust or
9398
change the caller's sources.
9499

95-
The PFX password is not stored in publisher state or normal diagnostics.
96-
`signtool`'s PFX interface requires passing it to the signing child process;
97-
privileged local process inspection can therefore observe it. Use a protected
98-
build agent and restrict access to both that agent and the PFX. The password
99-
environment-variable indirection is not a guarantee of process-level secret
100-
isolation.
100+
The PFX password is not stored in publisher state or normal diagnostics. The
101+
publisher hands it to `psign-tool` through a response file in a private
102+
temporary directory, deleted after signing, rather than on the command line.
103+
It is still present briefly on disk and in the signing process, so use a
104+
protected build agent and restrict access to both that agent and the PFX.
105+
106+
After signing, the publisher checks the package digests, that the signer
107+
certificate subject matches `--publisher` and carries the Code Signing usage,
108+
and that the signature chains to a trusted root. On Windows the root must be
109+
trusted by the machine, like `signtool verify /pa`; on Linux and macOS psign's
110+
Microsoft AuthRoot anchors are used. For a private or self-signed root, pass
111+
its certificate with `--trusted-ca` (repeatable) to `publish` and
112+
`verify --destination`.
101113

102114
## Host the result
103115

‎dotnet/src/Devolutions.Pinget.Cli/PublisherCommands.cs‎

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,14 @@ public static Command Create(Option<string?> outputOption)
6767
var stateVerify = new Option<string?>("--state", "Publisher working directory");
6868
var buildVerify = new Option<string?>("--build", "Build snapshot ID");
6969
var destinationVerify = new Option<string?>("--destination", "Published static source directory");
70-
var signToolVerify = new Option<string?>("--signtool", "Path to signtool.exe");
70+
var psignToolVerify = PsignToolOption();
71+
var trustedCaVerify = TrustedCaOption();
7172
var verify = new Command("verify", "Verify a build snapshot or published directory");
7273
verify.AddOption(stateVerify);
7374
verify.AddOption(buildVerify);
7475
verify.AddOption(destinationVerify);
75-
verify.AddOption(signToolVerify);
76+
verify.AddOption(psignToolVerify);
77+
verify.AddOption(trustedCaVerify);
7678
verify.SetHandler(context => Run(context, outputOption, () =>
7779
{
7880
var state = context.ParseResult.GetValueForOption(stateVerify);
@@ -81,8 +83,12 @@ public static Command Create(Option<string?> outputOption)
8183
throw new PublisherException("Provide exactly one of --state or --destination.");
8284
if (destination is not null && context.ParseResult.GetValueForOption(buildVerify) is not null)
8385
throw new PublisherException("--build cannot be combined with --destination.");
86+
var psignTool = context.ParseResult.GetValueForOption(psignToolVerify);
87+
var trustedCa = context.ParseResult.GetValueForOption(trustedCaVerify) ?? [];
88+
if (destination is null && (psignTool is not null || trustedCa.Length > 0))
89+
throw new PublisherException("--psign-tool and --trusted-ca require --destination.");
8490
var result = destination is not null
85-
? PublisherPackaging.VerifyDestination(destination, context.ParseResult.GetValueForOption(signToolVerify))
91+
? PublisherPackaging.VerifyDestination(destination, psignTool, trustedCa)
8692
: new PublisherWorkspace(state!).Verify(context.ParseResult.GetValueForOption(buildVerify));
8793
return (Object(
8894
("valid", JsonValue.Create(result.Valid)),
@@ -92,22 +98,17 @@ public static Command Create(Option<string?> outputOption)
9298
$"Verified {result.Subject}; artifacts: {result.ArtifactCount}.");
9399
}));
94100

95-
var makeAppxDoctor = new Option<string?>("--makeappx", "Path to makeappx.exe");
96-
var signToolDoctor = new Option<string?>("--signtool", "Path to signtool.exe");
97-
var doctor = new Command("doctor", "Check local publisher capabilities and packaging tools");
98-
doctor.AddOption(makeAppxDoctor);
99-
doctor.AddOption(signToolDoctor);
101+
var psignToolDoctor = PsignToolOption();
102+
var doctor = new Command("doctor", "Check local publisher capabilities and signing tools");
103+
doctor.AddOption(psignToolDoctor);
100104
doctor.SetHandler(context => Run(context, outputOption, () =>
101105
{
102-
var result = PublisherPackaging.Doctor(
103-
context.ParseResult.GetValueForOption(makeAppxDoctor),
104-
context.ParseResult.GetValueForOption(signToolDoctor));
106+
var result = PublisherPackaging.Doctor(context.ParseResult.GetValueForOption(psignToolDoctor));
105107
return (Object(
106108
("operatingSystem", JsonValue.Create(result.OperatingSystem)),
107-
("makeAppxPath", JsonValue.Create(result.MakeAppxPath)),
108-
("signToolPath", JsonValue.Create(result.SignToolPath)),
109+
("psignToolPath", JsonValue.Create(result.PsignToolPath)),
109110
("nativeMsZipAvailable", JsonValue.Create(result.NativeMsZipAvailable))),
110-
$"MSZIP: {(result.NativeMsZipAvailable ? "available" : "unavailable")}; makeappx: {result.MakeAppxPath ?? "not found"}; signtool: {result.SignToolPath ?? "not found"}.");
111+
$"MSZIP: {(result.NativeMsZipAvailable ? "available" : "unavailable")}; psign-tool: {result.PsignToolPath ?? "not found"}; signed publishing available: {(result.SignedPublishAvailable ? "yes" : "no")}.");
111112
}));
112113

113114
var statePublish = new Option<string>("--state", "Publisher working directory") { IsRequired = true };
@@ -118,15 +119,15 @@ public static Command Create(Option<string?> outputOption)
118119
var pfxPublish = new Option<string?>("--pfx", "PFX file used to sign the source MSIX");
119120
var passwordEnvPublish = new Option<string?>("--password-env", "Environment variable containing the PFX password");
120121
var unsignedPublish = new Option<bool>("--unsigned-development", "Allow explicitly unsigned development publishing");
121-
var makeAppxPublish = new Option<string?>("--makeappx", "Path to makeappx.exe");
122-
var signToolPublish = new Option<string?>("--signtool", "Path to signtool.exe");
123-
var timestampPublish = new Option<string?>("--timestamp-url", "HTTPS RFC 3161 timestamp service URL");
122+
var psignToolPublish = PsignToolOption();
123+
var trustedCaPublish = TrustedCaOption();
124+
var timestampPublish = new Option<string?>("--timestamp-url", "HTTP(S) RFC 3161 timestamp service URL");
124125
var buildPublish = new Option<string?>("--build", "Build snapshot ID (defaults to latest)");
125126
var publish = new Command("publish", "Package and publish a source2.msix and immutable artifacts");
126127
foreach (var option in new Option[]
127128
{
128129
statePublish, destinationPublish, namePublish, publisherPublish, versionPublish,
129-
pfxPublish, passwordEnvPublish, unsignedPublish, makeAppxPublish, signToolPublish, buildPublish, timestampPublish,
130+
pfxPublish, passwordEnvPublish, unsignedPublish, psignToolPublish, trustedCaPublish, buildPublish, timestampPublish,
130131
})
131132
publish.AddOption(option);
132133
publish.SetHandler(context => Run(context, outputOption, () =>
@@ -140,10 +141,10 @@ public static Command Create(Option<string?> outputOption)
140141
context.ParseResult.GetValueForOption(pfxPublish),
141142
context.ParseResult.GetValueForOption(passwordEnvPublish),
142143
context.ParseResult.GetValueForOption(unsignedPublish),
143-
context.ParseResult.GetValueForOption(makeAppxPublish),
144-
context.ParseResult.GetValueForOption(signToolPublish),
144+
context.ParseResult.GetValueForOption(psignToolPublish),
145145
context.ParseResult.GetValueForOption(buildPublish),
146-
context.ParseResult.GetValueForOption(timestampPublish)));
146+
context.ParseResult.GetValueForOption(timestampPublish),
147+
context.ParseResult.GetValueForOption(trustedCaPublish)));
147148
return (Object(
148149
("valid", JsonValue.Create(result.Valid)),
149150
("subject", JsonValue.Create(result.Subject)),
@@ -161,6 +162,16 @@ public static Command Create(Option<string?> outputOption)
161162
return root;
162163
}
163164

165+
private static Option<string?> PsignToolOption() =>
166+
new("--psign-tool", "Path to psign-tool (searched on PATH and in the .NET global tool directory when omitted)");
167+
168+
private static Option<string[]> TrustedCaOption() =>
169+
new("--trusted-ca", "Certificate trusted as a signature anchor instead of the platform default (repeatable)")
170+
{
171+
ArgumentHelpName = "certificate",
172+
AllowMultipleArgumentsPerToken = false,
173+
};
174+
164175
private static Option<string> InputOption()
165176
{
166177
var option = new Option<string>("--input", "Root directory containing WinGet YAML manifests") { IsRequired = true };

0 commit comments

Comments
 (0)