From 05e23857ddf9e6990b76660e2ef5c1b429bfe66b Mon Sep 17 00:00:00 2001 From: dh Date: Sat, 3 Oct 2026 15:00:48 +0200 Subject: [PATCH] ci: provision SDK code-signing roots in the disposable Wine prefix --- .gitea/workflows/pr-push-build-and-test.yaml | 15 ++ docs/wine-sdk-trust.md | 26 ++++ scripts/wine-sdk-trust.cs | 150 +++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 docs/wine-sdk-trust.md create mode 100644 scripts/wine-sdk-trust.cs diff --git a/.gitea/workflows/pr-push-build-and-test.yaml b/.gitea/workflows/pr-push-build-and-test.yaml index 44f74a1..789a0c0 100644 --- a/.gitea/workflows/pr-push-build-and-test.yaml +++ b/.gitea/workflows/pr-push-build-and-test.yaml @@ -73,6 +73,21 @@ jobs: echo "WIN_DOTNET_DIR=${WIN_DOTNET_DIR}" >> "${GITHUB_ENV}" "${WINE_BIN}" "${WIN_DOTNET_DIR}/dotnet.exe" --info + - name: Provision NuGet signature trust inside Wine + run: | + SDK_VERSION="$(dotnet --version)" + SDK_TRUST_ROOT="${DOTNET_ROOT}/sdk/${SDK_VERSION}/trustedroots" + dotnet publish scripts/wine-sdk-trust.cs \ + -c Release \ + -p:UseAppHost=false \ + -p:PublishAot=false \ + -o "${RUNNER_TEMP}/wine-sdk-trust" \ + --nologo + "${WINE_BIN}" "${WIN_DOTNET_DIR}/dotnet.exe" \ + "Z:${RUNNER_TEMP}/wine-sdk-trust/WineSdkTrust.dll" --import \ + "Z:${SDK_TRUST_ROOT}/codesignctl.pem" \ + "Z:${SDK_TRUST_ROOT}/timestampctl.pem" + - name: Diagnose Windows SDK targeting-pack resolution run: | find "${WIN_DOTNET_DIR}/packs" -maxdepth 4 -name PackageOverrides.txt -print diff --git a/docs/wine-sdk-trust.md b/docs/wine-sdk-trust.md new file mode 100644 index 0000000..ad67dc9 --- /dev/null +++ b/docs/wine-sdk-trust.md @@ -0,0 +1,26 @@ +# Wine SDK certificate trust + +`scripts/wine-sdk-trust.cs` is a .NET 10 file-based helper for validating the public root-certificate bundles distributed with the Microsoft SDK and importing them into a disposable Wine prefix's Windows `CurrentUser Root` store. It does not disable NuGet signature verification or certificate checks and does not import private keys. + +```sh +dotnet publish scripts/wine-sdk-trust.cs -c Release -p:UseAppHost=false -p:PublishAot=false -o artifacts/wine-sdk-trust +dotnet artifacts/wine-sdk-trust/WineSdkTrust.dll --validate /path/to/sdk/trustedroots/codesignctl.pem /path/to/sdk/trustedroots/timestampctl.pem +``` + +Validation is read-only on every OS. Both files must exist and contain public PEM certificates. Explicit non-CA certificates are rejected. Historical self-issued SDK roots without `BasicConstraints` remain valid; certificate expiry is not filtered because trusted bundles also support historical signatures. The helper logs each bundle's certificate count and SHA-256, then the unique certificate count. Use bundles from the verified Microsoft SDK archive and retain those hashes with the SDK provenance. + +For the Windows SDK installed inside a disposable Wine prefix, invoke the published DLL with `--import` and the two authoritative Linux SDK bundle paths, for example: + +```sh +"${WINE_BIN}" "${WIN_DOTNET_DIR}/dotnet.exe" artifacts/wine-sdk-trust/WineSdkTrust.dll --import \ + "Z:${DOTNET_ROOT}/sdk/10.0.401/trustedroots/codesignctl.pem" \ + "Z:${DOTNET_ROOT}/sdk/10.0.401/trustedroots/timestampctl.pem" +``` + +The import mode first requires `OperatingSystem.IsWindows()` and the Wine-specific `wine_get_version` export from `ntdll.dll`, located with `NativeLibrary.TryLoad` and `TryGetExport`. Native Windows is rejected as well as macOS/Linux. Only after both bundles validate does it open `CurrentUser Root` for writing and call `AddRange`. It closes the store, reopens it read-only and asserts that every imported thumbprint is present. There is no localized shell command, interactive prompt or GUI automation. Missing/unknown arguments and import attempts outside Wine return `2`; validation/import failures return `1`; successful validation or verified import returns `0`. + +The write side effect is confined to the selected Wine prefix's current-user root store. Repeated imports are safe. Run it only against the disposable CI prefix. Native validation never opens any certificate store, and native import attempts are rejected before parsing bundles or constructing a store. The normal PR workflow publishes this DLL and imports the bundles before the Windows desktop restore/build. This repair still requires a real Wine restore/build result to establish that it fixes the observed trust failure. + +[Microsoft documents](https://learn.microsoft.com/en-us/dotnet/core/tools/nuget-signed-package-verification) that these SDK bundles originate from its Trusted Root Program and provide code-signing and timestamping roots. The Windows NuGet restore remains responsible for checking actual package signatures; this helper populates the Wine store used for those checks. + +Local qualification on 2026-10-03 used SDK 10.0.203 to publish the DLL and the authoritative bundles from SDK 10.0.401 to validate it on macOS/.NET 10.0.7. Both bundles passed: codesign contained 307 certificates including seven historical roots without constraints; timestamp contained 327 including two such roots; the union contained 372 unique thumbprints. Their SHA-256 hashes were `AAB671F52E5229906B2100727370007EF4B6D2E360B23F2CF12A6D87773BE611` and `5CCB03367B52F047099F07E1653160E8578A83711CB18560C979FEBB65F8CB5D`, respectively. A native `--import` invocation with those same paths returned `2` before parsing bundles or opening a store; unknown arguments returned `2`, and an empty input returned `1`. No local store import was performed. This is helper qualification, not a passing Wine/NuGet result. diff --git a/scripts/wine-sdk-trust.cs b/scripts/wine-sdk-trust.cs new file mode 100644 index 0000000..09a4c74 --- /dev/null +++ b/scripts/wine-sdk-trust.cs @@ -0,0 +1,150 @@ +#:property PublishAot=false +#:property UseAppHost=false +#:property AssemblyName=WineSdkTrust + +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +if (args.Length != 3 || (args[0] != "--validate" && args[0] != "--import")) +{ + Console.Error.WriteLine("Usage: WineSdkTrust <--validate|--import> "); + return 2; +} + +var import = args[0] == "--import"; +if (import && (!OperatingSystem.IsWindows() || !IsWine())) +{ + Console.Error.WriteLine("REFUSED: --import requires Windows under Wine (ntdll.dll!wine_get_version). No certificate store was opened."); + return 2; +} + +Console.WriteLine($"OS: {RuntimeInformation.OSDescription}"); +Console.WriteLine($"Runtime: {RuntimeInformation.FrameworkDescription}"); +Console.WriteLine($"Mode: {args[0]}"); +var certificates = new X509Certificate2Collection(); +try +{ + foreach (var path in args.Skip(1)) + { + if (!File.Exists(path) || new FileInfo(path).Length == 0) + { + throw new InvalidDataException($"The SDK certificate bundle is missing or empty: {path}"); + } + + var bundle = new X509Certificate2Collection(); + try + { + bundle.ImportFromPemFile(path); + if (bundle.Count == 0) + { + throw new InvalidDataException($"The SDK bundle contains no PEM certificates: {path}"); + } + + var legacyRoots = 0; + foreach (var certificate in bundle) + { + if (certificate.HasPrivateKey) + { + throw new InvalidDataException($"The SDK bundle must contain public certificates only: {certificate.Thumbprint}"); + } + + var constraints = certificate.Extensions.OfType().SingleOrDefault(); + if (constraints is { CertificateAuthority: false }) + { + throw new InvalidDataException($"The SDK bundle contains a non-CA certificate: {certificate.Thumbprint}"); + } + if (constraints is null) + { + // Microsoft also ships historical roots without the BasicConstraints extension. + if (!certificate.SubjectName.RawData.AsSpan().SequenceEqual(certificate.IssuerName.RawData)) + { + throw new InvalidDataException($"A certificate without CA constraints is not self-issued: {certificate.Thumbprint}"); + } + legacyRoots++; + } + } + + Console.WriteLine($"Bundle: {Path.GetFullPath(path)}"); + Console.WriteLine($" SHA256: {Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)))}"); + Console.WriteLine($" Certificates: {bundle.Count}; historical self-issued roots without BasicConstraints: {legacyRoots}"); + certificates.AddRange(bundle); + bundle.Clear(); + } + finally + { + foreach (var certificate in bundle) + { + certificate.Dispose(); + } + } + } + + var thumbprints = certificates.Select(certificate => certificate.Thumbprint).ToHashSet(StringComparer.OrdinalIgnoreCase); + Console.WriteLine($"Unique SDK certificate thumbprints: {thumbprints.Count}"); + if (!import) + { + Console.WriteLine("PASS: both SDK bundles validated; no certificate store was opened."); + return 0; + } + + using (var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser)) + { + store.Open(OpenFlags.ReadWrite); + store.AddRange(certificates); + } + + using (var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser)) + { + store.Open(OpenFlags.ReadOnly); + var installed = store.Certificates; + try + { + var installedThumbprints = installed.Select(certificate => certificate.Thumbprint).ToHashSet(StringComparer.OrdinalIgnoreCase); + var missing = thumbprints.Except(installedThumbprints).ToArray(); + if (missing.Length != 0) + { + throw new CryptographicException($"SDK certificates missing after import: {string.Join(", ", missing)}"); + } + } + finally + { + foreach (var certificate in installed) + { + certificate.Dispose(); + } + } + } + + Console.WriteLine($"PASS: all {thumbprints.Count} SDK certificate thumbprints verified in CurrentUser Root."); + return 0; +} +catch (Exception exception) +{ + Console.Error.WriteLine($"FAIL: {exception.GetType().Name}: {exception.Message}"); + return 1; +} +finally +{ + foreach (var certificate in certificates) + { + certificate.Dispose(); + } +} + +static bool IsWine() +{ + if (!NativeLibrary.TryLoad("ntdll.dll", out var library)) + { + return false; + } + + try + { + return NativeLibrary.TryGetExport(library, "wine_get_version", out _); + } + finally + { + NativeLibrary.Free(library); + } +}