diff --git a/Program.cs b/Program.cs
index f126c4d..dfc2d74 100644
--- a/Program.cs
+++ b/Program.cs
@@ -1031,8 +1031,16 @@ 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})");
-
- switch (type.ToLower())
+
+ // 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.ToLowerInvariant())
{
case "powershell":
case "ps1":
@@ -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..c95f8d4
--- /dev/null
+++ b/src/BootstrapMate.Core/SignatureVerifier.cs
@@ -0,0 +1,207 @@
+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;
+ // 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;
+ 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);
+}