Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 12 additions & 27 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,17 +121,9 @@ jobs:
run: |
cargo build --manifest-path rust/Cargo.toml -p pinget-cli --release
if ($LASTEXITCODE -ne 0) { throw 'Rust publisher CLI build failed.' }
$sdkRoot = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin'
$makeAppx = Get-ChildItem -LiteralPath $sdkRoot -Directory |
Sort-Object Name -Descending |
ForEach-Object { Join-Path $_.FullName 'x64\makeappx.exe' } |
Where-Object { Test-Path -LiteralPath $_ } |
Select-Object -First 1
if (-not $makeAppx) { throw 'Windows SDK MakeAppx was not found.' }
& .\tests\publisher\run-production-conformance.ps1 `
-RustCli .\rust\target\release\pinget.exe `
-DotnetAssembly .\dotnet\src\Devolutions.Pinget.Cli\bin\Release\net10.0\pinget.dll `
-MakeAppxPath $makeAppx
-DotnetAssembly .\dotnet\src\Devolutions.Pinget.Cli\bin\Release\net10.0\pinget.dll
if ($LASTEXITCODE -ne 0) { throw 'Publisher interoperability checks failed.' }

- name: Build native COM DLLs
Expand Down Expand Up @@ -193,30 +185,22 @@ jobs:
-OutputRoot artifacts/ci-stock-winget-packages
if ($LASTEXITCODE -ne 0) { throw 'Building the disposable x64 publisher CLIs failed.' }

- name: Run signed stock WinGet acceptance with runner-local credentials
- name: Install psign-tool
shell: pwsh
run: |
$sdkRoot = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin'
$sdkTools = Get-ChildItem -LiteralPath $sdkRoot -Directory |
Sort-Object Name -Descending |
ForEach-Object {
[pscustomobject]@{
MakeAppx = Join-Path $_.FullName 'x64\makeappx.exe'
SignTool = Join-Path $_.FullName 'x64\signtool.exe'
}
} |
Where-Object {
(Test-Path -LiteralPath $_.MakeAppx) -and
(Test-Path -LiteralPath $_.SignTool)
} |
Select-Object -First 1
if (-not $sdkTools) { throw 'Windows SDK MakeAppx and SignTool were not found.' }
# 0.8.2 is the first release whose portable MSIX signatures verify on Windows.
dotnet tool install Devolutions.Psign.Tool --version $env:PSIGN_VERSION --tool-path artifacts/psign
if ($LASTEXITCODE -ne 0) { throw 'psign-tool installation failed.' }
env:
PSIGN_VERSION: 0.8.2

- name: Run signed stock WinGet acceptance with runner-local credentials
shell: pwsh
run: |
pwsh -NoLogo -NoProfile -File (Resolve-Path 'tests/publisher/run-ephemeral-host-publisher-gate.ps1') `
-RustCliPath (Resolve-Path 'artifacts/ci-stock-winget/rust/win-x64/pinget.exe') `
-DotnetCliPath (Resolve-Path 'artifacts/ci-stock-winget/dotnet-nativeaot/win-x64/pinget.exe') `
-MakeAppxPath $sdkTools.MakeAppx `
-SignToolPath $sdkTools.SignTool
-PsignToolPath (Resolve-Path 'artifacts/psign/psign-tool.cmd')
if ($LASTEXITCODE -ne 0) { throw 'Signed stock WinGet acceptance failed.' }

- name: Remove disposable build outputs
Expand All @@ -225,3 +209,4 @@ jobs:
run: |
Remove-Item -LiteralPath 'artifacts/ci-stock-winget' -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath 'artifacts/ci-stock-winget-packages' -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath 'artifacts/psign' -Recurse -Force -ErrorAction SilentlyContinue
42 changes: 27 additions & 15 deletions docs/repository-publishing.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,22 @@ URLs; the build does not download or execute them.
sets (version, installer, default locale and optional additional locales).
- Separate directories for inputs, publisher state and published files.
Do not put one inside another or use symlinks/reparse points in them.
- For publication on Windows, the Windows SDK's `makeappx.exe` and
`signtool.exe`. `repository doctor` reports tool availability; explicit tool
paths can be supplied when they are not discoverable.
- For signed publication, [`psign-tool`](https://github.com/Devolutions/psign)
**0.8.2 or later**, a cross-platform Authenticode signer: `dotnet tool
install -g Devolutions.Psign.Tool`. Earlier versions produce MSIX signatures
that Windows rejects. `repository doctor` reports whether it was found on
`PATH` or in the .NET global tool directory; `--psign-tool` supplies an
explicit path. The Windows SDK (`makeappx.exe`, `signtool.exe`) is not used:
the publisher writes the MSIX container itself.
- A code-signing certificate with its private key in a PFX, and a publisher
distinguished name matching that certificate.
distinguished name matching that certificate's subject.

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

## Build, inspect and publish

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

The PFX password is not stored in publisher state or normal diagnostics.
`signtool`'s PFX interface requires passing it to the signing child process;
privileged local process inspection can therefore observe it. Use a protected
build agent and restrict access to both that agent and the PFX. The password
environment-variable indirection is not a guarantee of process-level secret
isolation.
The PFX password is not stored in publisher state or normal diagnostics. The
publisher hands it to `psign-tool` through a response file in a private
temporary directory, deleted after signing, rather than on the command line.
It is still present briefly on disk and in the signing process, so use a
protected build agent and restrict access to both that agent and the PFX.

After signing, the publisher checks the package digests, that the signer
certificate subject matches `--publisher` and carries the Code Signing usage,
and that the signature chains to a trusted root. On Windows the root must be
trusted by the machine, like `signtool verify /pa`; on Linux and macOS psign's
Microsoft AuthRoot anchors are used. For a private or self-signed root, pass
its certificate with `--trusted-ca` (repeatable) to `publish` and
`verify --destination`.

## Host the result

Expand Down
53 changes: 32 additions & 21 deletions dotnet/src/Devolutions.Pinget.Cli/PublisherCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,14 @@ public static Command Create(Option<string?> outputOption)
var stateVerify = new Option<string?>("--state", "Publisher working directory");
var buildVerify = new Option<string?>("--build", "Build snapshot ID");
var destinationVerify = new Option<string?>("--destination", "Published static source directory");
var signToolVerify = new Option<string?>("--signtool", "Path to signtool.exe");
var psignToolVerify = PsignToolOption();
var trustedCaVerify = TrustedCaOption();
var verify = new Command("verify", "Verify a build snapshot or published directory");
verify.AddOption(stateVerify);
verify.AddOption(buildVerify);
verify.AddOption(destinationVerify);
verify.AddOption(signToolVerify);
verify.AddOption(psignToolVerify);
verify.AddOption(trustedCaVerify);
verify.SetHandler(context => Run(context, outputOption, () =>
{
var state = context.ParseResult.GetValueForOption(stateVerify);
Expand All @@ -81,8 +83,12 @@ public static Command Create(Option<string?> outputOption)
throw new PublisherException("Provide exactly one of --state or --destination.");
if (destination is not null && context.ParseResult.GetValueForOption(buildVerify) is not null)
throw new PublisherException("--build cannot be combined with --destination.");
var psignTool = context.ParseResult.GetValueForOption(psignToolVerify);
var trustedCa = context.ParseResult.GetValueForOption(trustedCaVerify) ?? [];
if (destination is null && (psignTool is not null || trustedCa.Length > 0))
throw new PublisherException("--psign-tool and --trusted-ca require --destination.");
var result = destination is not null
? PublisherPackaging.VerifyDestination(destination, context.ParseResult.GetValueForOption(signToolVerify))
? PublisherPackaging.VerifyDestination(destination, psignTool, trustedCa)
: new PublisherWorkspace(state!).Verify(context.ParseResult.GetValueForOption(buildVerify));
return (Object(
("valid", JsonValue.Create(result.Valid)),
Expand All @@ -92,22 +98,17 @@ public static Command Create(Option<string?> outputOption)
$"Verified {result.Subject}; artifacts: {result.ArtifactCount}.");
}));

var makeAppxDoctor = new Option<string?>("--makeappx", "Path to makeappx.exe");
var signToolDoctor = new Option<string?>("--signtool", "Path to signtool.exe");
var doctor = new Command("doctor", "Check local publisher capabilities and packaging tools");
doctor.AddOption(makeAppxDoctor);
doctor.AddOption(signToolDoctor);
var psignToolDoctor = PsignToolOption();
var doctor = new Command("doctor", "Check local publisher capabilities and signing tools");
doctor.AddOption(psignToolDoctor);
doctor.SetHandler(context => Run(context, outputOption, () =>
{
var result = PublisherPackaging.Doctor(
context.ParseResult.GetValueForOption(makeAppxDoctor),
context.ParseResult.GetValueForOption(signToolDoctor));
var result = PublisherPackaging.Doctor(context.ParseResult.GetValueForOption(psignToolDoctor));
return (Object(
("operatingSystem", JsonValue.Create(result.OperatingSystem)),
("makeAppxPath", JsonValue.Create(result.MakeAppxPath)),
("signToolPath", JsonValue.Create(result.SignToolPath)),
("psignToolPath", JsonValue.Create(result.PsignToolPath)),
("nativeMsZipAvailable", JsonValue.Create(result.NativeMsZipAvailable))),
$"MSZIP: {(result.NativeMsZipAvailable ? "available" : "unavailable")}; makeappx: {result.MakeAppxPath ?? "not found"}; signtool: {result.SignToolPath ?? "not found"}.");
$"MSZIP: {(result.NativeMsZipAvailable ? "available" : "unavailable")}; psign-tool: {result.PsignToolPath ?? "not found"}; signed publishing available: {(result.SignedPublishAvailable ? "yes" : "no")}.");
}));

var statePublish = new Option<string>("--state", "Publisher working directory") { IsRequired = true };
Expand All @@ -118,15 +119,15 @@ public static Command Create(Option<string?> outputOption)
var pfxPublish = new Option<string?>("--pfx", "PFX file used to sign the source MSIX");
var passwordEnvPublish = new Option<string?>("--password-env", "Environment variable containing the PFX password");
var unsignedPublish = new Option<bool>("--unsigned-development", "Allow explicitly unsigned development publishing");
var makeAppxPublish = new Option<string?>("--makeappx", "Path to makeappx.exe");
var signToolPublish = new Option<string?>("--signtool", "Path to signtool.exe");
var timestampPublish = new Option<string?>("--timestamp-url", "HTTPS RFC 3161 timestamp service URL");
var psignToolPublish = PsignToolOption();
var trustedCaPublish = TrustedCaOption();
var timestampPublish = new Option<string?>("--timestamp-url", "HTTP(S) RFC 3161 timestamp service URL");
var buildPublish = new Option<string?>("--build", "Build snapshot ID (defaults to latest)");
var publish = new Command("publish", "Package and publish a source2.msix and immutable artifacts");
foreach (var option in new Option[]
{
statePublish, destinationPublish, namePublish, publisherPublish, versionPublish,
pfxPublish, passwordEnvPublish, unsignedPublish, makeAppxPublish, signToolPublish, buildPublish, timestampPublish,
pfxPublish, passwordEnvPublish, unsignedPublish, psignToolPublish, trustedCaPublish, buildPublish, timestampPublish,
})
publish.AddOption(option);
publish.SetHandler(context => Run(context, outputOption, () =>
Expand All @@ -140,10 +141,10 @@ public static Command Create(Option<string?> outputOption)
context.ParseResult.GetValueForOption(pfxPublish),
context.ParseResult.GetValueForOption(passwordEnvPublish),
context.ParseResult.GetValueForOption(unsignedPublish),
context.ParseResult.GetValueForOption(makeAppxPublish),
context.ParseResult.GetValueForOption(signToolPublish),
context.ParseResult.GetValueForOption(psignToolPublish),
context.ParseResult.GetValueForOption(buildPublish),
context.ParseResult.GetValueForOption(timestampPublish)));
context.ParseResult.GetValueForOption(timestampPublish),
context.ParseResult.GetValueForOption(trustedCaPublish)));
return (Object(
("valid", JsonValue.Create(result.Valid)),
("subject", JsonValue.Create(result.Subject)),
Expand All @@ -161,6 +162,16 @@ public static Command Create(Option<string?> outputOption)
return root;
}

private static Option<string?> PsignToolOption() =>
new("--psign-tool", "Path to psign-tool (searched on PATH and in the .NET global tool directory when omitted)");

private static Option<string[]> TrustedCaOption() =>
new("--trusted-ca", "Certificate trusted as a signature anchor instead of the platform default (repeatable)")
{
ArgumentHelpName = "certificate",
AllowMultipleArgumentsPerToken = false,
};

private static Option<string> InputOption()
{
var option = new Option<string>("--input", "Root directory containing WinGet YAML manifests") { IsRequired = true };
Expand Down
Loading
Loading