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
59 changes: 57 additions & 2 deletions Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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");
}
}

/// <summary>
/// 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.
/// </summary>
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)
{
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions resources/BootstrapMate.admx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
<category name="Advanced" displayName="$(string.Cat_Advanced)">
<parentCategory ref="BootstrapMate" />
</category>
<category name="Security" displayName="$(string.Cat_Security)">
<parentCategory ref="BootstrapMate" />
</category>
</categories>

<policies>
Expand Down Expand Up @@ -222,5 +225,44 @@
</elements>
</policy>

<!-- ═══ Security ═══ -->

<policy name="VerifyPackageSignatures"
class="Machine"
displayName="$(string.VerifyPackageSignatures)"
explainText="$(string.VerifyPackageSignatures_Help)"
key="SOFTWARE\Policies\BootstrapMate"
valueName="VerifyPackageSignatures">
<parentCategory ref="Security" />
<supportedOn ref="windows:SUPPORTED_Windows_10_0" />
<enabledValue><decimal value="1" /></enabledValue>
<disabledValue><decimal value="0" /></disabledValue>
</policy>

<policy name="ExpectedPublisher"
class="Machine"
displayName="$(string.ExpectedPublisher)"
explainText="$(string.ExpectedPublisher_Help)"
key="SOFTWARE\Policies\BootstrapMate"
presentation="$(presentation.ExpectedPublisher)">
<parentCategory ref="Security" />
<supportedOn ref="windows:SUPPORTED_Windows_10_0" />
<elements>
<text id="ExpectedPublisher_Value" valueName="ExpectedPublisher" />
</elements>
</policy>

<policy name="AllowUnsigned"
class="Machine"
displayName="$(string.AllowUnsigned)"
explainText="$(string.AllowUnsigned_Help)"
key="SOFTWARE\Policies\BootstrapMate"
valueName="AllowUnsigned">
<parentCategory ref="Security" />
<supportedOn ref="windows:SUPPORTED_Windows_10_0" />
<enabledValue><decimal value="1" /></enabledValue>
<disabledValue><decimal value="0" /></disabledValue>
</policy>

</policies>
</policyDefinitions>
25 changes: 25 additions & 0 deletions resources/en-US/BootstrapMate.adml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
<string id="Cat_Behavior">Behavior</string>
<string id="Cat_Dialog">Dialog</string>
<string id="Cat_Advanced">Advanced</string>
<string id="Cat_Security">Security</string>

<!-- Connection -->
<string id="ManifestUrl">Manifest URL</string>
Expand Down Expand Up @@ -99,6 +100,24 @@ Default: C:\Program Files\BootstrapMate</string>

Range: 10–600 seconds
Default: 120 seconds</string>

<!-- Security -->
<string id="VerifyPackageSignatures">Verify package signatures</string>
<string id="VerifyPackageSignatures_Help">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</string>

<string id="ExpectedPublisher">Expected publisher</string>
<string id="ExpectedPublisher_Help">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.</string>

<string id="AllowUnsigned">Allow unsigned packages</string>
<string id="AllowUnsigned_Help">Permit unsigned or untrusted MSI/EXE installers to run (logged as a warning). A publisher mismatch is never bypassed by this setting.

Default: Disabled</string>
</stringTable>

<presentationTable>
Expand Down Expand Up @@ -141,6 +160,12 @@ Default: 120 seconds</string>
<presentation id="NetworkTimeout">
<decimalTextBox refId="NetworkTimeout_Value" defaultValue="120">Timeout (seconds):</decimalTextBox>
</presentation>

<presentation id="ExpectedPublisher">
<textBox refId="ExpectedPublisher_Value">
<label>Expected publisher:</label>
</textBox>
</presentation>
</presentationTable>
</resources>
</policyDefinitionResources>
5 changes: 5 additions & 0 deletions src/BootstrapMate.Core/BootstrapMateConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
26 changes: 25 additions & 1 deletion src/BootstrapMate.Core/ConfigManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
{
Expand All @@ -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;
}

/// <summary>Get the effective manifest URL from whatever source is active.</summary>
Expand Down Expand Up @@ -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 ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
{
Expand Down
3 changes: 3 additions & 0 deletions src/BootstrapMate.Core/PolicyDetector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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() { }
Expand Down
Loading
Loading