diff --git a/DialogManager.cs b/DialogManager.cs index 7d4deeb..1ee0f2b 100644 --- a/DialogManager.cs +++ b/DialogManager.cs @@ -317,12 +317,43 @@ private void LaunchDialog(List argList) CreateNoWindow = false, }; - _dialogProcess = Process.Start(startInfo); + // A shell-executed child cannot be given its own environment, only inherit + // ours, so the key is set on this process for the launch and removed again. + // It never goes on the command line or into the log. + var authKey = BootstrapMate.Core.DialogAuthKey.Resolve( + BootstrapMate.Core.ConfigManager.Instance.Config.DialogAuthKeyPath); + var callerKey = Environment.GetEnvironmentVariable(BootstrapMate.Core.DialogAuthKey.EnvironmentVariable); + try + { + if (authKey != null) + Environment.SetEnvironmentVariable(BootstrapMate.Core.DialogAuthKey.EnvironmentVariable, authKey); + _dialogProcess = Process.Start(startInfo); + } + finally + { + Environment.SetEnvironmentVariable(BootstrapMate.Core.DialogAuthKey.EnvironmentVariable, callerKey); + } + + // A dialog that refuses the key exits at once with 30. Catch that here so the + // run carries on headless instead of writing to a dialog that is not there. + if (_dialogProcess != null && _dialogProcess.WaitForExit(3000) && + _dialogProcess.ExitCode == BootstrapMate.Core.DialogAuthKey.KeyRequiredExitCode) + { + Logger.Warning(authKey == null + ? "csharpdialog requires an authorisation key and none was found; continuing without the dialog" + : "csharpdialog rejected the authorisation key; continuing without the dialog"); + _dialogProcess.Dispose(); + _dialogProcess = null; + _isAvailable = false; + return; + } if (_dialogProcess != null) { _isRunning = true; - Logger.Info("csharpdialog launched successfully"); + Logger.Info(authKey != null + ? "csharpdialog launched successfully (authorisation key supplied)" + : "csharpdialog launched successfully"); } else { diff --git a/README.md b/README.md index 55ecda3..daedba8 100644 --- a/README.md +++ b/README.md @@ -317,6 +317,17 @@ no simulated install, so a policy-set `DryRun` exits `1` without installing anyt sets the dialog's icon. `SilentMode` and `VerboseMode` from policy or settings turn those modes on, and a CLI switch cannot turn them off. +## csharpDialog authorisation key + +When csharpDialog is managed with an authorisation key, it opens only for a caller that +passes the matching plain key in the `DIALOG_AUTH_KEY` environment variable, and otherwise +exits `30`. BootstrapMate passes its own `DIALOG_AUTH_KEY` through when it has one, and +otherwise reads the key from the file named by `DialogAuthKeyPath` (policy or machine +settings; default `C:\ProgramData\ManagedNotifications\authkey`), which should be readable +only by SYSTEM and Administrators. The key is never put on the command line or logged. With +no key, or a wrong one, the dialog does not open, a warning is logged, and the run carries +on without it. + ## Registry Status Contract BootstrapMate tracks completion status in both 64-bit and 32-bit registry views: diff --git a/resources/BootstrapMate.admx b/resources/BootstrapMate.admx index 18c703c..0f1d543 100644 --- a/resources/BootstrapMate.admx +++ b/resources/BootstrapMate.admx @@ -166,6 +166,19 @@ + + + + + + + + Leave empty to use the default icon. + Dialog Authorisation Key File + Path to a file holding the plain csharpDialog authorisation key. When csharpDialog is managed with an authorisation key, BootstrapMate reads this file and passes the key to the dialog in the DIALOG_AUTH_KEY environment variable. The file should be readable only by SYSTEM and Administrators. + +If the key is missing or wrong the dialog does not open and the run continues without it. + +Default: C:\ProgramData\ManagedNotifications\authkey + Blur Screen Behind Dialog When enabled, the screen behind the progress dialog is blurred to prevent user interaction during setup. @@ -162,6 +169,12 @@ Default: Disabled + + + + + + diff --git a/src/BootstrapMate.Core/BootstrapMateConfig.cs b/src/BootstrapMate.Core/BootstrapMateConfig.cs index 5091bc2..19b571f 100644 --- a/src/BootstrapMate.Core/BootstrapMateConfig.cs +++ b/src/BootstrapMate.Core/BootstrapMateConfig.cs @@ -22,6 +22,8 @@ public sealed class BootstrapMateConfig public string? DialogIcon { get; set; } public bool BlurScreen { get; set; } public bool NoDialog { get; set; } + /// File holding the plain csharpDialog authorisation key; null means the default path. + public string? DialogAuthKeyPath { get; set; } // Reporting: vendor-neutral run-summary POST public string? ReportingUrl { get; set; } diff --git a/src/BootstrapMate.Core/BootstrapMateConstants.cs b/src/BootstrapMate.Core/BootstrapMateConstants.cs index 732d8b5..5a3e686 100644 --- a/src/BootstrapMate.Core/BootstrapMateConstants.cs +++ b/src/BootstrapMate.Core/BootstrapMateConstants.cs @@ -47,6 +47,12 @@ public static class BootstrapMateConstants /// Default dialog message. public const string DefaultDialogMessage = "Please wait while we install required software..."; + /// + /// Where the csharpDialog authorisation key is read from when no DialogAuthKeyPath is set. + /// The file should be readable only by SYSTEM and Administrators. + /// + public const string DefaultDialogAuthKeyPath = @"C:\ProgramData\ManagedNotifications\authkey"; + /// Named pipe prefix for GUI ↔ CLI communication. public const string PipeNamePrefix = "BootstrapMate_"; diff --git a/src/BootstrapMate.Core/ConfigManager.cs b/src/BootstrapMate.Core/ConfigManager.cs index 13af99c..d9af2f2 100644 --- a/src/BootstrapMate.Core/ConfigManager.cs +++ b/src/BootstrapMate.Core/ConfigManager.cs @@ -179,6 +179,7 @@ void WriteInt(string key, int value) WriteString("DialogMessage", settings.DialogMessage); WriteString("DialogIcon", settings.DialogIcon); WriteBool("BlurScreen", settings.BlurScreen); + WriteString("DialogAuthKeyPath", settings.DialogAuthKeyPath); WriteString("CustomInstallPath", settings.CustomInstallPath); WriteInt("NetworkTimeout", settings.NetworkTimeout); WriteInt("BaselineMinIntervalHours", settings.BaselineMinIntervalHours); @@ -265,6 +266,9 @@ private void LoadFromManagement(ManagementDetector management) if (management.GetManagedString("DialogIcon") is { Length: > 0 } icon) Config.DialogIcon = icon; + if (management.GetManagedString("DialogAuthKeyPath") is { Length: > 0 } keyPath) + Config.DialogAuthKeyPath = keyPath; + if (management.GetManagedBool("BlurScreen") is { } blur) Config.BlurScreen = blur; @@ -330,6 +334,7 @@ private void LoadFromHive(RegistryHive hive, ConfigSource source, RegistryView v Config.DialogMessage = ReadString(settingsKey, "DialogMessage") ?? Config.DialogMessage; Config.DialogIcon = ReadString(settingsKey, "DialogIcon") ?? Config.DialogIcon; Config.BlurScreen = ReadBool(settingsKey, "BlurScreen") ?? Config.BlurScreen; + Config.DialogAuthKeyPath = ReadString(settingsKey, "DialogAuthKeyPath") ?? Config.DialogAuthKeyPath; Config.CustomInstallPath = ReadString(settingsKey, "CustomInstallPath") ?? Config.CustomInstallPath; Config.NetworkTimeout = ReadInt(settingsKey, "NetworkTimeout") ?? Config.NetworkTimeout; Config.BaselineMinIntervalHours = ReadInt(settingsKey, "BaselineMinIntervalHours") ?? Config.BaselineMinIntervalHours; diff --git a/src/BootstrapMate.Core/DialogAuthKey.cs b/src/BootstrapMate.Core/DialogAuthKey.cs new file mode 100644 index 0000000..b3f213d --- /dev/null +++ b/src/BootstrapMate.Core/DialogAuthKey.cs @@ -0,0 +1,41 @@ +namespace BootstrapMate.Core; + +/// +/// The csharpDialog authorisation key. When policy sets an AuthorisationKey for csharpDialog, +/// the dialog refuses to open (exit 30) unless its caller passes the matching plain key in the +/// DIALOG_AUTH_KEY environment variable. The key reaches this process either already in that +/// variable or in a file only SYSTEM and Administrators can read. +/// +public static class DialogAuthKey +{ + /// The environment variable csharpDialog reads the key from. + public const string EnvironmentVariable = "DIALOG_AUTH_KEY"; + + /// The exit code csharpDialog returns when the key is missing or wrong. + public const int KeyRequiredExitCode = 30; + + /// + /// The key to hand csharpDialog, or null to launch it without one: the caller's own + /// DIALOG_AUTH_KEY if set, otherwise the trimmed contents of + /// (the default path when null or empty). A missing or unreadable file is not an error. + /// + public static string? Resolve(string? path, Func? readEnvironment = null) + { + var fromEnvironment = (readEnvironment ?? Environment.GetEnvironmentVariable)(EnvironmentVariable); + if (!string.IsNullOrWhiteSpace(fromEnvironment)) + return fromEnvironment.Trim(); + + var file = string.IsNullOrWhiteSpace(path) ? BootstrapMateConstants.DefaultDialogAuthKeyPath : path; + try + { + if (!File.Exists(file)) + return null; + var key = File.ReadAllText(file).Trim(); + return key.Length > 0 ? key : null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + } +} diff --git a/src/BootstrapMate.Core/ManagementDetector.cs b/src/BootstrapMate.Core/ManagementDetector.cs index 5f6f42a..ccd768c 100644 --- a/src/BootstrapMate.Core/ManagementDetector.cs +++ b/src/BootstrapMate.Core/ManagementDetector.cs @@ -33,6 +33,7 @@ public sealed class ManagementDetector ["DialogMessage"] = ["DialogMessage"], ["DialogIcon"] = ["DialogIcon"], ["BlurScreen"] = ["BlurScreen"], + ["DialogAuthKeyPath"] = ["DialogAuthKeyPath"], ["CustomInstallPath"] = ["CustomInstallPath", "InstallPath", "iapath"], ["NetworkTimeout"] = ["NetworkTimeout"], ["BaselineMinIntervalHours"] = ["BaselineMinIntervalHours"], diff --git a/tests/BootstrapMate.Core.Tests/DialogAuthKeyTests.cs b/tests/BootstrapMate.Core.Tests/DialogAuthKeyTests.cs new file mode 100644 index 0000000..284005e --- /dev/null +++ b/tests/BootstrapMate.Core.Tests/DialogAuthKeyTests.cs @@ -0,0 +1,43 @@ +using BootstrapMate.Core; +using Xunit; + +namespace BootstrapMate.Core.Tests; + +public class DialogAuthKeyTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "bm-authkey-" + Guid.NewGuid().ToString("N")); + + public DialogAuthKeyTests() => Directory.CreateDirectory(_dir); + + public void Dispose() => Directory.Delete(_dir, recursive: true); + + private static string? NoEnvironment(string _) => null; + + [Fact] + public void CallerEnvironmentWins() + { + var file = Path.Combine(_dir, "authkey"); + File.WriteAllText(file, "from-file"); + Assert.Equal("from-env", DialogAuthKey.Resolve(file, _ => " from-env ")); + } + + [Fact] + public void FileIsReadAndTrimmed() + { + var file = Path.Combine(_dir, "authkey"); + File.WriteAllText(file, " secret-key\r\n"); + Assert.Equal("secret-key", DialogAuthKey.Resolve(file, NoEnvironment)); + } + + [Fact] + public void MissingFileMeansNoKey() => + Assert.Null(DialogAuthKey.Resolve(Path.Combine(_dir, "absent"), NoEnvironment)); + + [Fact] + public void EmptyFileMeansNoKey() + { + var file = Path.Combine(_dir, "authkey"); + File.WriteAllText(file, "\r\n"); + Assert.Null(DialogAuthKey.Resolve(file, NoEnvironment)); + } +}