From 81e044b47d2298ee4c21f8855402a58cf8fa35b8 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Sun, 14 Jun 2026 22:49:34 -0700 Subject: [PATCH 1/2] Verify installer Authenticode signatures before running elevated DownloadAndInstallPackage handed any downloaded MSI/EXE straight to msiexec/the executable, running it elevated with no provenance check. The download only proves where the bytes came from, not who produced them: if the manifest or its host is compromised, an attacker-supplied installer runs as an elevated/SYSTEM process. Add SignatureVerifier (WinVerifyTrust + signer-certificate extraction) and gate InstallPackage on it for msi/exe items: the file must carry a signature that chains to a trusted root and, when configured, match an expected publisher. Unsigned/untrusted installers are refused unless AllowUnsigned is set; a publisher mismatch is never bypassed. Configurable via Intune CSP / Group Policy (new ADMX Security category: VerifyPackageSignatures, ExpectedPublisher, AllowUnsigned), the machine/user registry, and per-item manifest overrides (expectedPublisher, allowUnsigned). Defaults are secure. Mirrors the macOS SignatureVerifier. --- Program.cs | 57 ++++- README.md | 18 ++ resources/BootstrapMate.admx | 42 ++++ resources/en-US/BootstrapMate.adml | 25 +++ src/BootstrapMate.Core/BootstrapMateConfig.cs | 5 + src/BootstrapMate.Core/ConfigManager.cs | 26 ++- src/BootstrapMate.Core/PolicyDetector.cs | 3 + src/BootstrapMate.Core/SignatureVerifier.cs | 201 ++++++++++++++++++ 8 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 src/BootstrapMate.Core/SignatureVerifier.cs diff --git a/Program.cs b/Program.cs index f126c4d..471a4f4 100644 --- a/Program.cs +++ b/Program.cs @@ -1031,7 +1031,15 @@ static async Task DownloadAndInstallPackage(string displayName, string url, stri static async Task InstallPackage(string filePath, string type, JsonElement packageInfo) { Logger.Debug($"Installing package: {filePath} (Type: {type})"); - + + // Provenance gate for binary installers that run elevated. The download + // only proves where the bytes came from, not who produced them; verify + // the Authenticode signature before executing as an elevated process. + if (!VerifyInstallerSignature(filePath, type, packageInfo)) + { + throw new Exception($"Refusing to install {Path.GetFileName(filePath)}: signature verification failed"); + } + switch (type.ToLower()) { case "powershell": @@ -1091,6 +1099,53 @@ static async Task InstallPackage(string filePath, string type, JsonElement packa throw new Exception($"Unsupported package type: {type}. Supported types are: msi, exe, ps1, nupkg, pkg"); } } + + /// + /// Verify the Authenticode signature of a binary installer (msi/exe) before + /// it runs elevated. Returns true when the install may proceed. + /// + /// Only msi/exe are gated — those are PE/MSI files Authenticode can validate. + /// Per-item manifest fields (expectedPublisher, allowUnsigned) override the + /// global managed config. + /// + static bool VerifyInstallerSignature(string filePath, string type, JsonElement packageInfo) + { + var config = ConfigManager.Instance.Config; + if (!config.VerifyPackageSignatures) + return true; + + string normalizedType = type.ToLowerInvariant(); + if (normalizedType != "msi" && normalizedType != "exe") + return true; // nupkg/pkg/ps1 are not Authenticode-gated here + + string? expectedPublisher = config.ExpectedPublisher; + if (packageInfo.TryGetProperty("expectedPublisher", out var pubProp) && + pubProp.ValueKind == JsonValueKind.String && + !string.IsNullOrWhiteSpace(pubProp.GetString())) + { + expectedPublisher = pubProp.GetString(); + } + + bool allowUnsigned = config.AllowUnsigned; + if (packageInfo.TryGetProperty("allowUnsigned", out var allowProp) && + (allowProp.ValueKind == JsonValueKind.True || allowProp.ValueKind == JsonValueKind.False)) + { + allowUnsigned = allowProp.GetBoolean(); + } + + var result = SignatureVerifier.VerifyFile(filePath, expectedPublisher); + var (decision, reason) = SignatureVerifier.Decide(result, allowUnsigned); + + if (decision == SignatureVerifier.Decision.Allow) + { + Logger.Debug($"Signature check passed for {Path.GetFileName(filePath)}: {reason}"); + return true; + } + + Logger.Error($"Signature check failed for {Path.GetFileName(filePath)}: {reason}"); + Logger.WriteWarning($"Refusing to install {Path.GetFileName(filePath)} — {reason}"); + return false; + } static async Task RunPowerShellScript(string scriptPath, JsonElement packageInfo) { diff --git a/README.md b/README.md index cf1e2ac..650050a 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,24 @@ Before building, set up your environment variables: 3. **Install your code signing certificate** in the Current User certificate store +## Security: package signature verification + +Before any MSI or EXE installer is executed elevated, BootstrapMate verifies its Authenticode signature with `WinVerifyTrust`. A successful download only proves where the bytes came from — not who produced them. The signature gate ensures an installer carries a signature that chains to a trusted root (and, when configured, matches an expected publisher) before it runs as an elevated process. + +Behaviour is controlled via Intune CSP / Group Policy (the bundled ADMX), the machine/user registry, or per-item manifest fields. + +Policy / registry keys (`HKLM\SOFTWARE\Policies\BootstrapMate` for policy; `HKLM\SOFTWARE\BootstrapMate\Settings` for machine settings): + +| Key | Type | Default | Effect | +|---|---|---|---| +| `VerifyPackageSignatures` | DWORD | `1` | Verify every MSI/EXE installer before running it. | +| `ExpectedPublisher` | string | _unset_ | Require installers to be signed by a certificate whose common name/subject contains this value. When unset, any Windows-trusted signature is accepted. | +| `AllowUnsigned` | DWORD | `0` | Permit unsigned/untrusted installers (logged as a warning). A publisher *mismatch* is never bypassed, even with this set. | + +Per-item manifest overrides (fall back to the global config): `expectedPublisher`, `allowUnsigned`. + +Only `msi` and `exe` items are Authenticode-gated; `nupkg`/`pkg`/`ps1` items continue to rely on their existing handling. + ## Quick Start ```powershell diff --git a/resources/BootstrapMate.admx b/resources/BootstrapMate.admx index 4009aaf..c48f9e5 100644 --- a/resources/BootstrapMate.admx +++ b/resources/BootstrapMate.admx @@ -36,6 +36,9 @@ + + + @@ -222,5 +225,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/en-US/BootstrapMate.adml b/resources/en-US/BootstrapMate.adml index c7cfa37..00f46c8 100644 --- a/resources/en-US/BootstrapMate.adml +++ b/resources/en-US/BootstrapMate.adml @@ -20,6 +20,7 @@ Behavior Dialog Advanced + Security Manifest URL @@ -99,6 +100,24 @@ Default: C:\Program Files\BootstrapMate Range: 10–600 seconds Default: 120 seconds + + + Verify package signatures + Verify the Authenticode signature of MSI/EXE installers before running them elevated. The download only proves where bytes came from, not who produced them. + +When Enabled (or not configured), installers must carry a signature trusted by Windows. When Disabled, signature verification is skipped (NOT recommended). + +Default: Enabled + + Expected publisher + Require MSI/EXE installers to be signed by a certificate whose common name (or subject) contains this value (e.g. your organization name). + +When empty, any signature trusted by Windows is accepted. A publisher mismatch is always refused, even when "Allow unsigned packages" is enabled. + + Allow unsigned packages + Permit unsigned or untrusted MSI/EXE installers to run (logged as a warning). A publisher mismatch is never bypassed by this setting. + +Default: Disabled @@ -141,6 +160,12 @@ Default: 120 seconds Timeout (seconds): + + + + + + diff --git a/src/BootstrapMate.Core/BootstrapMateConfig.cs b/src/BootstrapMate.Core/BootstrapMateConfig.cs index 8c72ec6..bbb50d4 100644 --- a/src/BootstrapMate.Core/BootstrapMateConfig.cs +++ b/src/BootstrapMate.Core/BootstrapMateConfig.cs @@ -25,6 +25,11 @@ public sealed class BootstrapMateConfig public bool BlurScreen { get; set; } public bool NoDialog { get; set; } + // Security: installer signature verification + public bool VerifyPackageSignatures { get; set; } = true; + public string? ExpectedPublisher { get; set; } + public bool AllowUnsigned { get; set; } + // Advanced public string? CustomInstallPath { get; set; } public int NetworkTimeout { get; set; } = BootstrapMateConstants.DefaultNetworkTimeout; diff --git a/src/BootstrapMate.Core/ConfigManager.cs b/src/BootstrapMate.Core/ConfigManager.cs index c4c4f41..ba48ebb 100644 --- a/src/BootstrapMate.Core/ConfigManager.cs +++ b/src/BootstrapMate.Core/ConfigManager.cs @@ -49,7 +49,10 @@ public void ApplyCliArguments( bool? noDialog = null, string? dialogTitle = null, string? dialogMessage = null, - int? networkTimeout = null) + int? networkTimeout = null, + bool? verifyPackageSignatures = null, + string? expectedPublisher = null, + bool? allowUnsigned = null) { if (!string.IsNullOrWhiteSpace(manifestUrl)) { @@ -76,6 +79,12 @@ public void ApplyCliArguments( Config.DialogMessage = dialogMessage; if (networkTimeout.HasValue) Config.NetworkTimeout = networkTimeout.Value; + if (verifyPackageSignatures.HasValue) + Config.VerifyPackageSignatures = verifyPackageSignatures.Value; + if (!string.IsNullOrWhiteSpace(expectedPublisher)) + Config.ExpectedPublisher = expectedPublisher; + if (allowUnsigned.HasValue) + Config.AllowUnsigned = allowUnsigned.Value; } /// Get the effective manifest URL from whatever source is active. @@ -144,6 +153,9 @@ void WriteInt(string key, int value) WriteBool("BlurScreen", settings.BlurScreen); WriteString("CustomInstallPath", settings.CustomInstallPath); WriteInt("NetworkTimeout", settings.NetworkTimeout); + WriteBool("VerifyPackageSignatures", settings.VerifyPackageSignatures); + WriteString("ExpectedPublisher", settings.ExpectedPublisher); + WriteBool("AllowUnsigned", settings.AllowUnsigned); } // ── Private ────────────────────────────────────────────────────── @@ -215,6 +227,15 @@ private void LoadFromPolicy(PolicyDetector policy) if (policy.GetManagedInt("NetworkTimeout") is { } timeout) Config.NetworkTimeout = timeout; + + if (policy.GetManagedBool("VerifyPackageSignatures") is { } verifySig) + Config.VerifyPackageSignatures = verifySig; + + if (policy.GetManagedString("ExpectedPublisher") is { Length: > 0 } publisher) + Config.ExpectedPublisher = publisher; + + if (policy.GetManagedBool("AllowUnsigned") is { } allowUnsigned) + Config.AllowUnsigned = allowUnsigned; } private void LoadFromUserRegistry() @@ -260,6 +281,9 @@ private void LoadFromHive(RegistryHive hive, ConfigSource source, RegistryView v Config.BlurScreen = ReadBool(settingsKey, "BlurScreen") ?? Config.BlurScreen; Config.CustomInstallPath = ReadString(settingsKey, "CustomInstallPath") ?? Config.CustomInstallPath; Config.NetworkTimeout = ReadInt(settingsKey, "NetworkTimeout") ?? Config.NetworkTimeout; + Config.VerifyPackageSignatures = ReadBool(settingsKey, "VerifyPackageSignatures") ?? Config.VerifyPackageSignatures; + Config.ExpectedPublisher = ReadString(settingsKey, "ExpectedPublisher") ?? Config.ExpectedPublisher; + Config.AllowUnsigned = ReadBool(settingsKey, "AllowUnsigned") ?? Config.AllowUnsigned; } catch { diff --git a/src/BootstrapMate.Core/PolicyDetector.cs b/src/BootstrapMate.Core/PolicyDetector.cs index f2de0af..b880435 100644 --- a/src/BootstrapMate.Core/PolicyDetector.cs +++ b/src/BootstrapMate.Core/PolicyDetector.cs @@ -35,6 +35,9 @@ public sealed class PolicyDetector ["BlurScreen"] = ["BlurScreen"], ["CustomInstallPath"] = ["CustomInstallPath", "InstallPath", "iapath"], ["NetworkTimeout"] = ["NetworkTimeout"], + ["VerifyPackageSignatures"] = ["VerifyPackageSignatures", "VerifySignatures"], + ["ExpectedPublisher"] = ["ExpectedPublisher", "Publisher"], + ["AllowUnsigned"] = ["AllowUnsigned"], }; private PolicyDetector() { } diff --git a/src/BootstrapMate.Core/SignatureVerifier.cs b/src/BootstrapMate.Core/SignatureVerifier.cs new file mode 100644 index 0000000..1676667 --- /dev/null +++ b/src/BootstrapMate.Core/SignatureVerifier.cs @@ -0,0 +1,201 @@ +using System.Runtime.InteropServices; +using System.Security.Cryptography.X509Certificates; + +namespace BootstrapMate.Core; + +/// +/// Verifies the Authenticode provenance of installer files (MSI/EXE) before they +/// are executed elevated. +/// +/// The download only guarantees the bytes came from the configured URL — it does +/// not prove the file was produced by a trusted publisher. If the manifest or its +/// host is compromised, an attacker-supplied installer would otherwise run as an +/// elevated/SYSTEM process. WinVerifyTrust confirms the embedded signature is +/// present, intact, and chains to a trusted root; the signer certificate's common +/// name is then matched against an optionally-configured expected publisher. +/// +/// Windows counterpart of the macOS SignatureVerifier (pkgutil --check-signature). +/// +public static class SignatureVerifier +{ + public enum Status + { + /// Valid signature that chains to a trusted root. + Trusted, + /// Unsigned, tampered, or not trusted by Windows. + Untrusted, + /// Trusted signature, but the publisher does not match the expected value. + PublisherMismatch + } + + public readonly record struct Result(Status Status, string? Publisher, string Detail); + + public enum Decision { Allow, Deny } + + /// + /// Inspect an installer file's Authenticode signature. + /// + /// Path to the MSI/EXE on disk. + /// + /// When set, the signer certificate's common name (or full subject) must contain this value. + /// + public static Result VerifyFile(string filePath, string? expectedPublisher) + { + uint trust = WinVerifyTrustFile(filePath); + if (trust != 0) + { + // Non-zero HRESULT means unsigned, tampered, or untrusted chain. + return new Result(Status.Untrusted, null, $"WinVerifyTrust=0x{trust:X8}"); + } + + string? publisher = TryGetPublisher(filePath); + + if (!string.IsNullOrWhiteSpace(expectedPublisher)) + { + if (publisher is null || + publisher.IndexOf(expectedPublisher, StringComparison.OrdinalIgnoreCase) < 0) + { + return new Result(Status.PublisherMismatch, publisher, $"expected publisher '{expectedPublisher}'"); + } + } + + return new Result(Status.Trusted, publisher, "signature trusted"); + } + + /// + /// Apply administrator policy to a verification result. + /// permits an untrusted/unsigned file (with a + /// warning). A publisher *mismatch* is never bypassed — an explicit mismatch is + /// a strong tampering signal. + /// + public static (Decision Decision, string Reason) Decide(Result result, bool allowUnsigned) + { + return result.Status switch + { + Status.Trusted => (Decision.Allow, + result.Publisher is { Length: > 0 } p ? $"signature trusted ({p})" : "signature trusted"), + Status.Untrusted => allowUnsigned + ? (Decision.Allow, $"untrusted signature ({result.Detail}) — allowed because AllowUnsigned is set") + : (Decision.Deny, $"untrusted or missing signature ({result.Detail})"), + Status.PublisherMismatch => (Decision.Deny, + $"publisher mismatch — found '{result.Publisher ?? "none"}', {result.Detail}"), + _ => (Decision.Deny, "unknown verification status") + }; + } + + private static string? TryGetPublisher(string filePath) + { + try + { + var cert = X509Certificate.CreateFromSignedFile(filePath); + var subject = cert.Subject; // e.g. "CN=Example Corp, O=Example, C=US" + return ExtractCommonName(subject) ?? subject; + } + catch + { + return null; + } + } + + /// Extract the CN value from an X.500 subject string (best-effort). + internal static string? ExtractCommonName(string subject) + { + foreach (var part in subject.Split(',')) + { + var trimmed = part.Trim(); + if (trimmed.StartsWith("CN=", StringComparison.OrdinalIgnoreCase)) + return trimmed[3..].Trim().Trim('"'); + } + return null; + } + + // ── WinVerifyTrust P/Invoke ─────────────────────────────────────── + + private static readonly Guid WINTRUST_ACTION_GENERIC_VERIFY_V2 = + new("00AAC56B-CD44-11d0-8CC2-00C04FC295EE"); + + private const uint WTD_UI_NONE = 2; + private const uint WTD_REVOKE_NONE = 0; + private const uint WTD_CHOICE_FILE = 1; + private const uint WTD_STATEACTION_VERIFY = 1; + private const uint WTD_STATEACTION_CLOSE = 2; + private const uint WTD_SAFER_FLAG = 0x100; + + private static uint WinVerifyTrustFile(string filePath) + { + var fileInfo = new WINTRUST_FILE_INFO + { + cbStruct = (uint)Marshal.SizeOf(), + pcwszFilePath = filePath, + hFile = IntPtr.Zero, + pgKnownSubject = IntPtr.Zero + }; + + IntPtr pFile = Marshal.AllocHGlobal(Marshal.SizeOf()); + try + { + Marshal.StructureToPtr(fileInfo, pFile, false); + + var data = new WINTRUST_DATA + { + cbStruct = (uint)Marshal.SizeOf(), + pPolicyCallbackData = IntPtr.Zero, + pSIPClientData = IntPtr.Zero, + dwUIChoice = WTD_UI_NONE, + fdwRevocationChecks = WTD_REVOKE_NONE, + dwUnionChoice = WTD_CHOICE_FILE, + pFile = pFile, + dwStateAction = WTD_STATEACTION_VERIFY, + hWVTStateData = IntPtr.Zero, + pwszURLReference = IntPtr.Zero, + dwProvFlags = WTD_SAFER_FLAG, + dwUIContext = 0, + pSignatureSettings = IntPtr.Zero + }; + + Guid action = WINTRUST_ACTION_GENERIC_VERIFY_V2; + uint result = WinVerifyTrust(IntPtr.Zero, ref action, ref data); + + // Always release the state data, regardless of the verify result. + data.dwStateAction = WTD_STATEACTION_CLOSE; + WinVerifyTrust(IntPtr.Zero, ref action, ref data); + + return result; + } + finally + { + Marshal.DestroyStructure(pFile); + Marshal.FreeHGlobal(pFile); + } + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct WINTRUST_FILE_INFO + { + public uint cbStruct; + [MarshalAs(UnmanagedType.LPWStr)] public string pcwszFilePath; + public IntPtr hFile; + public IntPtr pgKnownSubject; + } + + [StructLayout(LayoutKind.Sequential)] + private struct WINTRUST_DATA + { + public uint cbStruct; + public IntPtr pPolicyCallbackData; + public IntPtr pSIPClientData; + public uint dwUIChoice; + public uint fdwRevocationChecks; + public uint dwUnionChoice; + public IntPtr pFile; + public uint dwStateAction; + public IntPtr hWVTStateData; + public IntPtr pwszURLReference; + public uint dwProvFlags; + public uint dwUIContext; + public IntPtr pSignatureSettings; + } + + [DllImport("wintrust.dll", ExactSpelling = true, SetLastError = false)] + private static extern uint WinVerifyTrust(IntPtr hwnd, ref Guid pgActionID, ref WINTRUST_DATA pWVTData); +} From aa5ba1d9f3b808436daa98203220937e77fe88b8 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Mon, 15 Jun 2026 16:53:40 -0700 Subject: [PATCH 2/2] Address review: ToLowerInvariant for type switch; document revocation choice - Use ToLowerInvariant() for the package-type switch to avoid locale-dependent comparison. - Add a comment explaining why WinVerifyTrust revocation checking is left off (OOBE/ESP often runs without reliable networking); the trusted-root chain is still verified, and the knob to enable WHOLECHAIN is noted. --- Program.cs | 2 +- src/BootstrapMate.Core/SignatureVerifier.cs | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Program.cs b/Program.cs index 471a4f4..dfc2d74 100644 --- a/Program.cs +++ b/Program.cs @@ -1040,7 +1040,7 @@ static async Task InstallPackage(string filePath, string type, JsonElement packa throw new Exception($"Refusing to install {Path.GetFileName(filePath)}: signature verification failed"); } - switch (type.ToLower()) + switch (type.ToLowerInvariant()) { case "powershell": case "ps1": diff --git a/src/BootstrapMate.Core/SignatureVerifier.cs b/src/BootstrapMate.Core/SignatureVerifier.cs index 1676667..c95f8d4 100644 --- a/src/BootstrapMate.Core/SignatureVerifier.cs +++ b/src/BootstrapMate.Core/SignatureVerifier.cs @@ -115,6 +115,12 @@ public static (Decision Decision, string Reason) Decide(Result result, bool allo new("00AAC56B-CD44-11d0-8CC2-00C04FC295EE"); private const uint WTD_UI_NONE = 2; + // Revocation checking is intentionally left off (WTD_REVOKE_NONE). BootstrapMate + // runs during OOBE / the Autopilot ESP, where networking is often unavailable or + // unreliable; an online OCSP/CRL check would add latency and could hard-fail an + // otherwise-valid installer when the revocation endpoint is unreachable. The trust + // chain to a trusted root is still verified. If your environment needs revocation, + // switch this to WTD_REVOKE_WHOLECHAIN. private const uint WTD_REVOKE_NONE = 0; private const uint WTD_CHOICE_FILE = 1; private const uint WTD_STATEACTION_VERIFY = 1;