diff --git a/README.md b/README.md index 5ae6c8f..bd28779 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,11 @@ managedstatekeeper list-overrides managedstatekeeper checksum myscript.ps1 managedstatekeeper checksum all --record +# Sign and verify scripts (matching outset); see Script Signing +managedstatekeeper sign myscript.ps1 +managedstatekeeper verify myscript.ps1 --public-key +managedstatekeeper generate-keypair + # Show version managedstatekeeper --version ``` @@ -120,7 +125,7 @@ Each setting is read from the first of these that sets it: Environment variables are not a source. Both registry keys are read in the 64-bit view. -Every setting can be set at every level. In the registry the value name is the setting's name below, with booleans and numbers as `REG_DWORD` and lists as `REG_MULTI_SZ`; the Config.yaml name (`wait_for_network`) is accepted as an alias. +Every setting except `ManifestSigningKey`, which only policy can set, can be set at every level. In the registry the value name is the setting's name below, with booleans and numbers as `REG_DWORD` and lists as `REG_MULTI_SZ`; the Config.yaml name (`wait_for_network`) is accepted as an alias. | Setting | Config.yaml | Default | |---|---|---| @@ -143,6 +148,7 @@ Every setting can be set at every level. In the registry the value name is the s | `LogScriptOutput` | `log_script_output` | `1` | | `IgnoredUsers` | `ignored_users` | empty | | `Overrides` | `overrides` | empty | +| `ManifestSigningKey` | policy only | unset; see [Script Signing](#script-signing) | `resources/StartSet.admx` with `resources/en-US/StartSet.adml` is an administrative template for every setting above, for Group Policy (copy them to `C:\Windows\PolicyDefinitions` or the central store) or Intune (Imported Administrative templates). Each policy writes the value of the same name under `HKLM\SOFTWARE\Policies\StartSet`, and Managed State Keeper shows it as managed by policy and locks the field. The template's Security category also carries `ManifestSigningKey`, which StartSet reads from policy only and does not show on the Prefs tab. @@ -169,6 +175,38 @@ An owner always holds the right to change a file's permissions, so the service g The first time the service locks a folder that was open before, any file in it whose owner is not an administrator could have come from a standard user. The service moves each such file to `C:\ProgramData\ManagedState\quarantine\\` and logs it; nothing is deleted. After that first lock, files are only given to Administrators. +## Script Signing + +StartSet can require every payload to be signed, the way outset does on the Mac with `manifest_signing_key`. Set an Ed25519 public key, base64 of the raw 32 bytes, as the `ManifestSigningKey` string value under `HKLM\SOFTWARE\Policies\StartSet`. Only policy can set it: a value in machine settings, `Config.yaml` or on the command line is ignored and logged. + +While it is set, a script runs only if it carries a valid signature line made with the matching private key. Anything else is refused, not run, logged as an error and reported in `items.json` as an error with the reason: + +- a script with no signature, or one changed after signing (line endings included); +- an `.exe`, `.msi` or `.msix`, which cannot carry a signature; +- every payload, when the policy value is not a valid key. + +The signature is one line in the script: `# ed25519: ` in a `.ps1`, `REM ed25519: ` in a `.cmd` or `.bat`. It covers the file's bytes with that line removed (split on LF, rejoined with LF, a leading UTF-8 BOM kept), so for an LF PowerShell script it is the same signature outset makes and checks, and one key can sign for both. + +Generate a keypair, and keep the private key out of source control: + +```powershell +managedstatekeeper generate-keypair +``` + +Sign scripts in place. The private key comes from an environment variable, `STARTSET_SIGNING_KEY` unless `--key-env` names another, or from `--key-file`; it is never taken as an argument: + +```powershell +managedstatekeeper sign .\login-every\Wallpaper.ps1 .\boot-every\Setup.cmd +``` + +Check signatures, against the policy key or one you pass. The exit code is 1 if any file fails: + +```powershell +managedstatekeeper verify .\login-every\Wallpaper.ps1 --public-key +``` + +Sign after the last edit: any change, including a line-ending conversion by git, breaks the signature. + ## Trigger Files Create one of these files to run payloads now. The service watches `C:\ProgramData\ManagedState\triggers`, the one folder a standard user may create files in, and the data root itself: diff --git a/resources/StartSet.admx b/resources/StartSet.admx index 4c53d59..0df0370 100644 --- a/resources/StartSet.admx +++ b/resources/StartSet.admx @@ -18,8 +18,9 @@ setting, add its policy to the matching category; a new Prefs group gets a new category under StartSet. A test fails while any setting lacks a policy here. - Security holds values StartSet reads from policy only (PolicyTemplateTests.PolicyOnlyValues), - which are not on the Prefs tab and are ignored in machine settings and Config.yaml. + Security holds values StartSet reads from policy only (PolicyTemplateTests.PolicyOnlyValues, + [PolicyOnly] in StartSetPreferences), which are ignored in machine settings, Config.yaml and on + the command line, and shown locked on the Prefs tab. --> Manifest signing public key - The public key StartSet uses to verify signed script manifests: the base64 encoding of a raw 32-byte Ed25519 public key (44 characters). + Requires every payload to be signed. The base64 encoding of a raw 32-byte Ed25519 public key (44 characters). -StartSet reads this value from policy only. The same value in machine settings or Config.yaml is ignored, so only an administrator managing the device can choose which signer is trusted. +While set, a script runs only if it carries a valid "# ed25519:" (.ps1) or "REM ed25519:" (.cmd, .bat) signature line made with the matching private key. Unsigned or modified scripts, executables and packages are refused and reported as errors, as is every payload when this value is not a valid key. The format is outset's, so one key can sign for macOS and Windows. -This value is not shown on the Prefs tab of Managed State Keeper. +StartSet reads this value from policy only. The same value in machine settings, Config.yaml or on the command line is ignored, so only an administrator managing the device can choose which signer is trusted. The Prefs tab of Managed State Keeper shows it as managed. Writes ManifestSigningKey (REG_SZ). diff --git a/src/App/ViewModels/PrefsViewModel.cs b/src/App/ViewModels/PrefsViewModel.cs index 4a774be..be35a17 100644 --- a/src/App/ViewModels/PrefsViewModel.cs +++ b/src/App/ViewModels/PrefsViewModel.cs @@ -105,7 +105,7 @@ public void Load() Kind = KindOf(setting), Value = setting.Property.GetValue(preferences), Source = source, - CanEdit = PrefsElevation.CanEdit(IsElevated, source == SettingSource.Policy), + CanEdit = PrefsElevation.CanEdit(IsElevated, source == SettingSource.Policy || setting.PolicyOnly), }; }) .ToList(); diff --git a/src/CLI/Commands/BootCommand.cs b/src/CLI/Commands/BootCommand.cs index 1e85a2a..86ed673 100644 --- a/src/CLI/Commands/BootCommand.cs +++ b/src/CLI/Commands/BootCommand.cs @@ -31,7 +31,7 @@ public static Command Create(PreferencesService preferencesService) username: null, waitForNetwork: !skipNetwork); - var failed = results.Count(r => r.Status == ExecutionStatus.Failed); + var failed = results.Count(r => r.Status is ExecutionStatus.Failed or ExecutionStatus.SignatureRejected); Environment.ExitCode = failed > 0 ? 1 : 0; }, skipNetworkOption); diff --git a/src/CLI/Commands/LoginCommand.cs b/src/CLI/Commands/LoginCommand.cs index 1d36706..a93917d 100644 --- a/src/CLI/Commands/LoginCommand.cs +++ b/src/CLI/Commands/LoginCommand.cs @@ -32,7 +32,7 @@ public static Command Create(PreferencesService preferencesService) username: user, waitForNetwork: false); - var failed = results.Count(r => r.Status == ExecutionStatus.Failed); + var failed = results.Count(r => r.Status is ExecutionStatus.Failed or ExecutionStatus.SignatureRejected); Environment.ExitCode = failed > 0 ? 1 : 0; }, usernameOption); diff --git a/src/CLI/Commands/LoginPrivilegedCommand.cs b/src/CLI/Commands/LoginPrivilegedCommand.cs index cf5f491..ba5efda 100644 --- a/src/CLI/Commands/LoginPrivilegedCommand.cs +++ b/src/CLI/Commands/LoginPrivilegedCommand.cs @@ -32,7 +32,7 @@ public static Command Create(PreferencesService preferencesService) username: user, waitForNetwork: false); - var failed = results.Count(r => r.Status == ExecutionStatus.Failed); + var failed = results.Count(r => r.Status is ExecutionStatus.Failed or ExecutionStatus.SignatureRejected); Environment.ExitCode = failed > 0 ? 1 : 0; }, usernameOption); diff --git a/src/CLI/Commands/LoginWindowCommand.cs b/src/CLI/Commands/LoginWindowCommand.cs index 4449b44..4d563de 100644 --- a/src/CLI/Commands/LoginWindowCommand.cs +++ b/src/CLI/Commands/LoginWindowCommand.cs @@ -25,7 +25,7 @@ public static Command Create(PreferencesService preferencesService) username: null, waitForNetwork: false); - var failed = results.Count(r => r.Status == ExecutionStatus.Failed); + var failed = results.Count(r => r.Status is ExecutionStatus.Failed or ExecutionStatus.SignatureRejected); Environment.ExitCode = failed > 0 ? 1 : 0; }); diff --git a/src/CLI/Commands/OnDemandCommand.cs b/src/CLI/Commands/OnDemandCommand.cs index 8517de1..f909fd9 100644 --- a/src/CLI/Commands/OnDemandCommand.cs +++ b/src/CLI/Commands/OnDemandCommand.cs @@ -32,7 +32,7 @@ public static Command Create(PreferencesService preferencesService) username: Environment.UserName, waitForNetwork: false); - var failed = results.Count(r => r.Status == ExecutionStatus.Failed); + var failed = results.Count(r => r.Status is ExecutionStatus.Failed or ExecutionStatus.SignatureRejected); Environment.ExitCode = failed > 0 ? 1 : 0; }, privilegedOption); diff --git a/src/CLI/Commands/ProcessingCommand.cs b/src/CLI/Commands/ProcessingCommand.cs index 26367f8..7007b56 100644 --- a/src/CLI/Commands/ProcessingCommand.cs +++ b/src/CLI/Commands/ProcessingCommand.cs @@ -51,7 +51,7 @@ public static Command Create(PreferencesService preferencesService) username: username ?? (payloadType.Value.IsUserContext() ? Environment.UserName : null), waitForNetwork: !skipNetwork && payloadType.Value == PayloadType.BootOnce); - var failed = results.Count(r => r.Status == ExecutionStatus.Failed); + var failed = results.Count(r => r.Status is ExecutionStatus.Failed or ExecutionStatus.SignatureRejected); Environment.ExitCode = failed > 0 ? 1 : 0; }, typeArgument, usernameOption, skipNetworkOption); diff --git a/src/CLI/Commands/SigningCommand.cs b/src/CLI/Commands/SigningCommand.cs new file mode 100644 index 0000000..bcfeef0 --- /dev/null +++ b/src/CLI/Commands/SigningCommand.cs @@ -0,0 +1,158 @@ +using System.CommandLine; +using StartSet.Infrastructure.Configuration; +using StartSet.Infrastructure.Security; + +namespace StartSet.CLI.Commands; + +/// +/// Commands to sign and verify script payloads and to make a signing keypair. +/// Match outset's --sign-script-file, --verify-script-file and --generate-keypair. +/// +public static class SigningCommand +{ + public const string DefaultKeyVariable = "STARTSET_SIGNING_KEY"; + + /// The command names, which run without a session log. + public static readonly HashSet Names = new(StringComparer.OrdinalIgnoreCase) { "sign", "verify", "generate-keypair" }; + + /// + /// startset sign <files...> [--key-env VAR | --key-file PATH]. The private key is never + /// taken as a plain argument, which would leave it in shell history and process listings. + /// + public static Command CreateSign() + { + var command = new Command("sign", "Embed an Ed25519 signature in one or more scripts (.ps1, .cmd, .bat), in place"); + + var filesArgument = new Argument("files", "Scripts to sign") { Arity = ArgumentArity.OneOrMore }; + var keyEnvOption = new Option( + aliases: ["--key-env"], + getDefaultValue: () => DefaultKeyVariable, + description: "Environment variable holding the base64 private key"); + var keyFileOption = new Option( + aliases: ["--key-file"], + description: "File holding the base64 private key; used instead of --key-env"); + + command.AddArgument(filesArgument); + command.AddOption(keyEnvOption); + command.AddOption(keyFileOption); + + command.SetHandler(context => + { + var files = context.ParseResult.GetValueForArgument(filesArgument); + var keyEnv = context.ParseResult.GetValueForOption(keyEnvOption)!; + var keyFile = context.ParseResult.GetValueForOption(keyFileOption); + string? encoded; + try + { + encoded = keyFile is not null ? File.ReadAllText(keyFile) : Environment.GetEnvironmentVariable(keyEnv); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Could not read the key file: {ex.Message}"); + context.ExitCode = 1; + return; + } + + var privateKey = ScriptSigning.ParsePrivateKey(encoded); + if (privateKey is null) + { + Console.Error.WriteLine(keyFile is not null + ? $"{keyFile} does not hold a base64 Ed25519 private key" + : $"{keyEnv} is not set to a base64 Ed25519 private key"); + context.ExitCode = 1; + return; + } + + var failures = 0; + foreach (var file in files) + { + try + { + ScriptSigning.SignFile(Resolve(file), privateKey); + Console.WriteLine($"Signed: {file}"); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Could not sign {file}: {ex.Message}"); + failures++; + } + } + + context.ExitCode = failures > 0 ? 1 : 0; + }); + + return command; + } + + /// startset verify <files...> [--public-key B64]. Exits 1 when any file fails. + public static Command CreateVerify(PreferencesService preferencesService) + { + var command = new Command("verify", "Check the Ed25519 signature embedded in one or more scripts"); + + var filesArgument = new Argument("files", "Scripts to verify") { Arity = ArgumentArity.OneOrMore }; + var publicKeyOption = new Option( + aliases: ["--public-key"], + description: "Base64 public key to verify against. Default: the ManifestSigningKey policy value"); + + command.AddArgument(filesArgument); + command.AddOption(publicKeyOption); + + command.SetHandler(context => + { + var files = context.ParseResult.GetValueForArgument(filesArgument); + var publicKey = context.ParseResult.GetValueForOption(publicKeyOption); + var encoded = publicKey ?? preferencesService.Preferences.ManifestSigningKey; + if (encoded is null) + { + Console.Error.WriteLine("No public key: pass --public-key, or set ManifestSigningKey by policy"); + context.ExitCode = 1; + return; + } + + var key = ScriptSigning.ParsePublicKey(encoded); + var failures = 0; + foreach (var file in files) + { + var path = Resolve(file); + var check = File.Exists(path) + ? ScriptSigning.VerifyFile(path, key) + : new SignatureCheck(SignatureFailure.Unreadable, $"file not found at {path}"); + if (check.IsValid) + { + Console.WriteLine($"Valid: {file}"); + } + else + { + Console.Error.WriteLine($"Invalid: {file}: {check.Describe()}"); + failures++; + } + } + + context.ExitCode = failures > 0 ? 1 : 0; + }); + + return command; + } + + /// + /// A file argument as an absolute path, a relative one taken from the directory the command + /// was run in, so the result never depends on what the process later treats as current. + /// + internal static string Resolve(string file) => + Path.GetFullPath(file, Environment.CurrentDirectory); + + /// startset generate-keypair. Prints a new public and private key. + public static Command CreateGenerateKeypair() + { + var command = new Command("generate-keypair", "Generate an Ed25519 keypair for script signing"); + + command.SetHandler(() => + { + var (publicKey, privateKey) = ScriptSigning.GenerateKeypair(); + Console.WriteLine($"Public key (set as the ManifestSigningKey policy value): {publicKey}"); + Console.WriteLine($"Private key (keep secret; signs scripts): {privateKey}"); + }); + + return command; + } +} diff --git a/src/CLI/Program.cs b/src/CLI/Program.cs index 84f7000..aa5767e 100644 --- a/src/CLI/Program.cs +++ b/src/CLI/Program.cs @@ -38,6 +38,20 @@ public static async Task Main(string[] args) preferencesService.Load(); StartSetLogger.Initialize(preferencesService.Preferences, isService: false); + // Signing tools touch only the files they are given. They run on build agents and + // admin workstations, so they open no session under ProgramData and write no report. + if (args.Length > 0 && SigningCommand.Names.Contains(args[0])) + { + try + { + return await BuildRootCommand(preferencesService).InvokeAsync(args); + } + finally + { + StartSetLogger.CloseAndFlush(); + } + } + // Start a logging session (day-nested timestamped directory) using var sessionLogger = new SessionLogger(); var runType = args.Length > 0 ? args[0] : "cli"; @@ -128,6 +142,11 @@ private static RootCommand BuildRootCommand(PreferencesService preferencesServic // Checksum command (matching outset) rootCommand.AddCommand(ChecksumCommand.Create(preferencesService)); + // Script signing + rootCommand.AddCommand(SigningCommand.CreateSign()); + rootCommand.AddCommand(SigningCommand.CreateVerify(preferencesService)); + rootCommand.AddCommand(SigningCommand.CreateGenerateKeypair()); + return rootCommand; } } diff --git a/src/Core/Enums/ExecutionStatus.cs b/src/Core/Enums/ExecutionStatus.cs index 66e073f..e5f4abc 100644 --- a/src/Core/Enums/ExecutionStatus.cs +++ b/src/Core/Enums/ExecutionStatus.cs @@ -41,5 +41,13 @@ public enum ExecutionStatus /// of problem invisible: a login payload that silently did nothing looked /// exactly like one that worked. /// - Deferred + Deferred, + + /// + /// Script signing is required by policy and the payload's signature did not verify -- + /// none, a bad one, a file type that cannot carry one, or an unusable policy key. The + /// payload was refused and not run. Reported as a failure, never a skip: a payload that + /// was refused has not applied its settings, and that has to be visible. + /// + SignatureRejected } diff --git a/src/Core/Models/ExecutionResult.cs b/src/Core/Models/ExecutionResult.cs index 88e083c..4ba41aa 100644 --- a/src/Core/Models/ExecutionResult.cs +++ b/src/Core/Models/ExecutionResult.cs @@ -110,6 +110,17 @@ public class ExecutionResult StartTime = DateTimeOffset.UtcNow }; + /// + /// Creates a result for a payload refused because its signature did not verify. + /// + public static ExecutionResult SignatureRejected(ScriptPayload script, string reason) => new() + { + Script = script, + Status = ExecutionStatus.SignatureRejected, + ErrorMessage = $"Refused: {reason}", + StartTime = DateTimeOffset.UtcNow + }; + /// /// Creates a timeout result. /// diff --git a/src/Core/Models/PolicyOnlyAttribute.cs b/src/Core/Models/PolicyOnlyAttribute.cs new file mode 100644 index 0000000..46fb0b8 --- /dev/null +++ b/src/Core/Models/PolicyOnlyAttribute.cs @@ -0,0 +1,15 @@ +namespace StartSet.Core.Models; + +/// +/// Marks a setting that only policy (HKLM\SOFTWARE\Policies\StartSet) may set. A value for it +/// in machine settings, Config.yaml or on the command line is ignored and logged. +/// +/// +/// For settings that exist to constrain what StartSet runs. If an administrator on the +/// machine -- or anything running as one -- could set them in a writable place, they would +/// constrain nothing. +/// +[AttributeUsage(AttributeTargets.Property)] +public sealed class PolicyOnlyAttribute : Attribute +{ +} diff --git a/src/Core/Models/ScriptPayload.cs b/src/Core/Models/ScriptPayload.cs index 4631806..f8a48bd 100644 --- a/src/Core/Models/ScriptPayload.cs +++ b/src/Core/Models/ScriptPayload.cs @@ -62,6 +62,13 @@ public class ScriptPayload /// public int SortOrder { get; set; } + /// + /// Why the payload is refused under required script signing, or null when it is not. + /// A refused payload is reported as + /// rather than skipped. + /// + public string? SignatureRejection { get; set; } + /// /// Returns true if this is a PowerShell script. /// diff --git a/src/Core/Models/StartSetPreferences.cs b/src/Core/Models/StartSetPreferences.cs index b81dbb2..3a4ff45 100644 --- a/src/Core/Models/StartSetPreferences.cs +++ b/src/Core/Models/StartSetPreferences.cs @@ -219,6 +219,21 @@ public class StartSetPreferences [YamlMember(Alias = "overrides")] public List Overrides { get; set; } = []; + /// + /// Base64 Ed25519 public key (the raw 32 bytes). When set, StartSet runs only script + /// payloads that carry a valid embedded signature made with the matching private key, + /// and refuses every other payload -- unsigned or tampered scripts, and executables and + /// packages, which cannot carry one. Default: null (no signing required). + /// + /// Policy only. A key anyone with write access to machine settings or Config.yaml could + /// set would let them swap in their own key and sign their own payloads, so a value + /// from anywhere but policy is ignored. Same rule as outset's manifest_signing_key, + /// which is honoured only when MDM-managed. + /// + [PolicyOnly] + [YamlMember(Alias = "manifest_signing_key")] + public string? ManifestSigningKey { get; set; } + /// /// Returns default preferences. /// diff --git a/src/Engine/ExecutionEngine.cs b/src/Engine/ExecutionEngine.cs index 8c33eb4..516f999 100644 --- a/src/Engine/ExecutionEngine.cs +++ b/src/Engine/ExecutionEngine.cs @@ -138,10 +138,11 @@ public async Task> ExecuteAsync( var failed = results.Count(r => r.Status == ExecutionStatus.Failed); var skipped = results.Count(r => r.Status == ExecutionStatus.Skipped); var deferred = results.Count(r => r.Status == ExecutionStatus.Deferred); + var refused = results.Count(r => r.Status == ExecutionStatus.SignatureRejected); StartSetLogger.Information( - "Execution complete: {Succeeded} succeeded, {Failed} failed, {Skipped} skipped, {Deferred} deferred", - succeeded, failed, skipped, deferred); + "Execution complete: {Succeeded} succeeded, {Failed} failed, {Skipped} skipped, {Deferred} deferred, {Refused} refused (signature)", + succeeded, failed, skipped, deferred, refused); // Say it twice, and plainly. A deferral means user-visible work did not // happen, and it is the line someone reading a log after a complaint @@ -267,9 +268,9 @@ private async Task> ExecutePayloadTypeAsync( if (cancellationToken.IsCancellationRequested) break; - if (DateTimeOffset.UtcNow >= batchDeadline && !script.ShouldSkip) + if (DateTimeOffset.UtcNow >= batchDeadline && !script.ShouldSkip && script.SignatureRejection is null) { - var remaining = ordered.Skip(index).Where(s => !s.ShouldSkip).ToList(); + var remaining = ordered.Skip(index).Where(s => !s.ShouldSkip && s.SignatureRejection is null).ToList(); StartSetLogger.Warning( "Login batch budget of {Budget}s is spent. Abandoning {Count} payload(s) so the session is not held any longer: {Names}. They run again at the next logon.", @@ -289,7 +290,13 @@ private async Task> ExecutePayloadTypeAsync( ExecutionResult result; - if (script.ShouldSkip) + if (script.SignatureRejection is not null) + { + // Ahead of every skip: a refused payload must report as refused, not as + // "already ran" or anything else that reads as fine. + result = ExecutionResult.SignatureRejected(script, script.SignatureRejection); + } + else if (script.ShouldSkip) { result = ExecutionResult.Skipped(script, script.SkipReason ?? "Unknown reason"); StartSetLogger.Debug("Skipping script: {Script} - {Reason}", script.FileName, script.SkipReason ?? "Unknown"); @@ -364,6 +371,16 @@ private List DiscoverScripts(string directory, PayloadType payloa var prefs = _preferencesService.Preferences; var allowedExtensions = prefs.AllowedExtensions.Select(e => e.ToLowerInvariant()).ToHashSet(); + // Script signing is on when policy sets a key. A key that does not decode does not + // turn signing off -- that would make a typo in policy, or a tampered value, an + // off switch -- it refuses everything until the policy is fixed. + var signingRequired = prefs.ManifestSigningKey is not null; + var signingKey = signingRequired ? ScriptSigning.ParsePublicKey(prefs.ManifestSigningKey) : null; + if (signingRequired && signingKey is null) + { + StartSetLogger.Error("Script signing is required but the ManifestSigningKey policy value is not a base64 Ed25519 public key. Every payload is refused until it is corrected"); + } + try { var files = Directory.GetFiles(directory) @@ -407,6 +424,20 @@ private List DiscoverScripts(string directory, PayloadType payloa StartSetLogger.Warning("Skipping {Script}: {Reason}", script.FileName, script.SkipReason); } + if (signingRequired) + { + var check = ScriptSigning.VerifyFile(filePath, signingKey); + if (!check.IsValid) + { + script.SignatureRejection = check.Describe(); + StartSetLogger.Error("Refusing {Script}: {Reason}", filePath, script.SignatureRejection); + } + else + { + StartSetLogger.Debug("Signature verified for {Script}", script.FileName); + } + } + scripts.Add(script); } catch (Exception ex) @@ -538,6 +569,7 @@ private static void EmitScriptOutput(ScriptPayload script, ExecutionResult resul ExecutionStatus.Success => "completed", ExecutionStatus.Deferred => "deferred", ExecutionStatus.Skipped => "skipped", + ExecutionStatus.SignatureRejected => "refused", _ => "failed" }, $"Exit code {result.ExitCode}", diff --git a/src/Infrastructure/Configuration/PreferenceResolver.cs b/src/Infrastructure/Configuration/PreferenceResolver.cs index 7e7912d..2e1c8d3 100644 --- a/src/Infrastructure/Configuration/PreferenceResolver.cs +++ b/src/Infrastructure/Configuration/PreferenceResolver.cs @@ -18,10 +18,14 @@ public enum SettingSource /// /// One setting in . Its registry value name is the property /// name (WaitForNetwork); the Config.yaml name (wait_for_network) is accepted as an alias. +/// A setting is read from policy alone. /// public sealed record PreferenceSetting(string Name, string YamlName, PropertyInfo Property) { public Type ValueType => Property.PropertyType; + + /// True when only policy may set this; see . + public bool PolicyOnly => Property.GetCustomAttribute() is not null; } /// The effective preferences, and which source supplied each one. @@ -81,6 +85,15 @@ public static PreferenceResolution Resolve( { sources[setting.Name] = SettingSource.Default; + if (setting.PolicyOnly) + { + IgnoreUnlessPolicy(setting, FromDictionary(legacyFile, setting), SettingSource.LegacyFile); + IgnoreUnlessPolicy(setting, FromStore(machine, setting), SettingSource.MachineSettings); + IgnoreUnlessPolicy(setting, FromDictionary(commandLine, setting), SettingSource.CommandLine); + Apply(setting, FromStore(policy, setting), SettingSource.Policy); + continue; + } + Apply(setting, FromDictionary(legacyFile, setting), SettingSource.LegacyFile); Apply(setting, FromStore(machine, setting), SettingSource.MachineSettings); Apply(setting, FromStore(policy, setting), SettingSource.Policy); @@ -89,6 +102,12 @@ public static PreferenceResolution Resolve( return new PreferenceResolution { Preferences = preferences, Sources = sources, Notes = notes }; + void IgnoreUnlessPolicy(PreferenceSetting setting, object? raw, SettingSource source) + { + if (raw is null) return; + notes.Add($"{setting.Name} from {Describe(source)} is ignored: only policy (HKLM\\SOFTWARE\\Policies\\StartSet) can set it"); + } + void Apply(PreferenceSetting setting, object? raw, SettingSource source) { if (raw is null) return; diff --git a/src/Infrastructure/Configuration/PreferencesService.cs b/src/Infrastructure/Configuration/PreferencesService.cs index 3ffa197..345ed92 100644 --- a/src/Infrastructure/Configuration/PreferencesService.cs +++ b/src/Infrastructure/Configuration/PreferencesService.cs @@ -144,6 +144,8 @@ public void SaveMachineSetting(string name, object value) ?? throw new ArgumentException($"Unknown setting: {name}", nameof(name)); if (IsManaged(setting.Name)) throw new InvalidOperationException($"{setting.Name} is set by policy and cannot be changed here"); + if (setting.PolicyOnly) + throw new InvalidOperationException($"{setting.Name} can only be set by policy"); if (_machine is null) throw new InvalidOperationException("No machine settings store is available"); diff --git a/src/Infrastructure/Configuration/SettingDescriptions.cs b/src/Infrastructure/Configuration/SettingDescriptions.cs index 749f09a..e38aba8 100644 --- a/src/Infrastructure/Configuration/SettingDescriptions.cs +++ b/src/Infrastructure/Configuration/SettingDescriptions.cs @@ -14,8 +14,9 @@ public static class SettingDescriptions public const string Execution = "Execution"; public const string Login = "Login"; public const string Logging = "Logging"; + public const string Security = "Security"; - public static IReadOnlyList Groups { get; } = [Network, Execution, Login, Logging]; + public static IReadOnlyList Groups { get; } = [Network, Execution, Login, Logging, Security]; private static readonly Dictionary Descriptions = new(StringComparer.OrdinalIgnoreCase) { @@ -28,6 +29,7 @@ public static class SettingDescriptions ["ChecksumValidation"] = new(Execution, "Require checksums", "Runs only payloads whose checksum is recorded."), ["ParallelExecution"] = new(Execution, "Parallel execution", "Not implemented yet: payloads still run one at a time."), ["Overrides"] = new(Execution, "Run-once overrides", "Run-once payloads to run again. One file name per line."), + ["ManifestSigningKey"] = new(Security, "Script signing key", "Ed25519 public key. When set, only scripts signed with the matching private key run. Set by policy only."), ["LoginScriptTimeout"] = new(Login, "Login payload timeout", "Longest one payload in a user's session may run.", "seconds"), ["LoginBatchBudget"] = new(Login, "Login batch budget", "Total time for one batch of login payloads.", "seconds"), diff --git a/src/Infrastructure/Logging/SessionLogger.cs b/src/Infrastructure/Logging/SessionLogger.cs index 153afbb..ee026f7 100644 --- a/src/Infrastructure/Logging/SessionLogger.cs +++ b/src/Infrastructure/Logging/SessionLogger.cs @@ -604,7 +604,9 @@ public static ItemRecord BuildItemRecord(ScriptPayload script, ExecutionResult r LastUpdate = nowUtc.ToString("o"), FailureCount = status == "Error" ? 1 : 0, Type = "startset", - ActionPerformed = acted ? (script.IsPackage ? "install" : "execute") : null + ActionPerformed = acted && result.Status != ExecutionStatus.SignatureRejected + ? (script.IsPackage ? "install" : "execute") + : null }; if (status == "Error") diff --git a/src/Infrastructure/Security/ScriptSigning.cs b/src/Infrastructure/Security/ScriptSigning.cs new file mode 100644 index 0000000..284669d --- /dev/null +++ b/src/Infrastructure/Security/ScriptSigning.cs @@ -0,0 +1,262 @@ +using System.Text; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Crypto.Signers; +using Org.BouncyCastle.Security; + +namespace StartSet.Infrastructure.Security; + +/// Why a payload's signature did not verify. +public enum SignatureFailure +{ + None, + + /// The file type cannot carry an embedded signature line (executables, packages). + UnsupportedType, + + /// The file has no signature line. + NoSignature, + + /// The signature line is not 64 bytes of base64. + MalformedSignature, + + /// The signature does not match the file and the key. + BadSignature, + + /// The public key is not 32 bytes of base64. + BadKey, + + /// The file could not be read. + Unreadable +} + +/// Outcome of checking one payload's signature. +public readonly record struct SignatureCheck(SignatureFailure Failure, string? Detail = null) +{ + public bool IsValid => Failure == SignatureFailure.None; + + public static SignatureCheck Valid => new(SignatureFailure.None); + + /// One line for the log and the items report. + public string Describe() => Failure switch + { + SignatureFailure.None => "signature verified", + SignatureFailure.UnsupportedType => "this file type cannot carry an embedded signature, and script signing is required", + SignatureFailure.NoSignature => "no signature: the script carries no ed25519 signature line, and script signing is required", + SignatureFailure.MalformedSignature => "bad signature: the ed25519 signature line is not a valid signature", + SignatureFailure.BadSignature => "bad signature: the script does not match its ed25519 signature (modified after signing, or signed with another key)", + SignatureFailure.BadKey => "bad key: the ManifestSigningKey policy value is not a base64 Ed25519 public key, so no payload can be verified", + SignatureFailure.Unreadable => $"the file could not be read to verify its signature{(Detail is null ? "" : $": {Detail}")}", + _ => Failure.ToString() + }; +} + +/// +/// Ed25519 signatures embedded in script payloads, in the format outset uses on the Mac. +/// +/// +/// +/// A script carries its signature as one comment line: # ed25519: <base64> in a +/// PowerShell script, REM ed25519: <base64> in a batch file. The signed bytes are +/// the file with that line removed, so the signature covers a stable payload that can be +/// read and versioned without it. +/// +/// +/// Canonical payload, exactly: the raw file bytes; a leading UTF-8 byte order mark is kept +/// but not part of the first line for prefix matching; the rest is split on LF; every line +/// that starts with the file type's prefix is dropped (a CR at the end of a line stays part +/// of that line); the remaining lines are joined with LF. For a PowerShell script with LF +/// line endings and no BOM this is byte for byte what outset signs, so one key and one +/// signing tool serve both platforms. +/// +/// +public static class ScriptSigning +{ + private const int SignatureSize = 64; + private const int KeySize = 32; + + private static readonly byte[] Utf8Bom = [0xEF, 0xBB, 0xBF]; + + /// Signature line prefix for each script type that can carry one. + private static readonly Dictionary Prefixes = new(StringComparer.OrdinalIgnoreCase) + { + [".ps1"] = "# ed25519: ", + [".cmd"] = "REM ed25519: ", + [".bat"] = "REM ed25519: ", + }; + + /// The signature line prefix for 's type, or null when it cannot carry one. + public static string? PrefixFor(string path) => + Prefixes.TryGetValue(Path.GetExtension(path), out var prefix) ? prefix : null; + + /// True when files of this type can carry an embedded signature. + public static bool CanCarrySignature(string path) => PrefixFor(path) is not null; + + /// Decodes a base64 raw 32-byte Ed25519 public key; null when it is not one. + public static Ed25519PublicKeyParameters? ParsePublicKey(string? base64) + { + var bytes = DecodeBase64(base64); + return bytes is { Length: KeySize } ? new Ed25519PublicKeyParameters(bytes) : null; + } + + /// Decodes a base64 raw 32-byte Ed25519 private key (the seed); null when it is not one. + public static Ed25519PrivateKeyParameters? ParsePrivateKey(string? base64) + { + var bytes = DecodeBase64(base64); + return bytes is { Length: KeySize } ? new Ed25519PrivateKeyParameters(bytes) : null; + } + + /// A new keypair as base64 raw keys: (public, private). + public static (string PublicKey, string PrivateKey) GenerateKeypair() + { + var privateKey = new Ed25519PrivateKeyParameters(new SecureRandom()); + return (Convert.ToBase64String(privateKey.GeneratePublicKey().GetEncoded()), + Convert.ToBase64String(privateKey.GetEncoded())); + } + + /// The bytes a signature covers: the file without its signature lines. + public static byte[] CanonicalContent(byte[] content, string prefix) + { + var (bom, lines) = Split(content); + var prefixBytes = Encoding.UTF8.GetBytes(prefix); + return Join(bom, lines.Where(line => !StartsWith(line, prefixBytes))); + } + + /// Signs a script file's content and returns the content with the signature line embedded. + /// The file type cannot carry a signature. + public static byte[] Sign(byte[] content, string path, Ed25519PrivateKeyParameters privateKey) + { + var prefix = PrefixFor(path) + ?? throw new ArgumentException($"{Path.GetExtension(path)} files cannot carry an embedded signature", nameof(path)); + + var signature = SignBytes(CanonicalContent(content, prefix), privateKey); + + var (bom, lines) = Split(content); + var prefixBytes = Encoding.UTF8.GetBytes(prefix); + var kept = lines.Where(line => !StartsWith(line, prefixBytes)).ToList(); + + // Match the file's line endings, judged by its first line, so signing a CRLF batch + // file does not leave one LF-only line in it. + var crlf = lines.Count > 1 && lines[0].Length > 0 && lines[0][^1] == (byte)'\r'; + var signatureLine = Encoding.UTF8.GetBytes(prefix + Convert.ToBase64String(signature) + (crlf ? "\r" : "")); + + // After a shebang, so the interpreter line stays first; otherwise at the top. + var insertAt = kept.Count > 0 && StartsWith(kept[0], "#!"u8.ToArray()) ? 1 : 0; + kept.Insert(insertAt, signatureLine); + + return Join(bom, kept); + } + + /// Signs a file in place. + public static void SignFile(string path, Ed25519PrivateKeyParameters privateKey) => + File.WriteAllBytes(path, Sign(File.ReadAllBytes(path), path, privateKey)); + + /// Checks the signature embedded in . + public static SignatureCheck Verify(byte[] content, string path, Ed25519PublicKeyParameters? publicKey) + { + if (publicKey is null) + return new SignatureCheck(SignatureFailure.BadKey); + + var prefix = PrefixFor(path); + if (prefix is null) + return new SignatureCheck(SignatureFailure.UnsupportedType); + + var prefixBytes = Encoding.UTF8.GetBytes(prefix); + var (_, lines) = Split(content); + var signatureLine = lines.FirstOrDefault(line => StartsWith(line, prefixBytes)); + if (signatureLine is null) + return new SignatureCheck(SignatureFailure.NoSignature); + + var encoded = Encoding.UTF8.GetString(signatureLine, prefixBytes.Length, signatureLine.Length - prefixBytes.Length).Trim(); + var signature = DecodeBase64(encoded); + if (signature is not { Length: SignatureSize }) + return new SignatureCheck(SignatureFailure.MalformedSignature); + + var verifier = new Ed25519Signer(); + verifier.Init(false, publicKey); + var payload = CanonicalContent(content, prefix); + verifier.BlockUpdate(payload, 0, payload.Length); + return verifier.VerifySignature(signature) + ? SignatureCheck.Valid + : new SignatureCheck(SignatureFailure.BadSignature); + } + + /// Checks the signature embedded in the file at . + public static SignatureCheck VerifyFile(string path, Ed25519PublicKeyParameters? publicKey) + { + if (publicKey is null) + return new SignatureCheck(SignatureFailure.BadKey); + if (!CanCarrySignature(path)) + return new SignatureCheck(SignatureFailure.UnsupportedType); + + byte[] content; + try + { + content = File.ReadAllBytes(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return new SignatureCheck(SignatureFailure.Unreadable, ex.Message); + } + + return Verify(content, path, publicKey); + } + + /// Raw Ed25519 signature over . + public static byte[] SignBytes(byte[] payload, Ed25519PrivateKeyParameters privateKey) + { + var signer = new Ed25519Signer(); + signer.Init(true, privateKey); + signer.BlockUpdate(payload, 0, payload.Length); + return signer.GenerateSignature(); + } + + private static (byte[] Bom, List Lines) Split(byte[] content) + { + var hasBom = content.AsSpan().StartsWith(Utf8Bom); + var body = hasBom ? content.AsSpan(Utf8Bom.Length) : content.AsSpan(); + + var lines = new List(); + while (true) + { + var newline = body.IndexOf((byte)'\n'); + if (newline < 0) + { + lines.Add(body.ToArray()); + break; + } + lines.Add(body[..newline].ToArray()); + body = body[(newline + 1)..]; + } + + return (hasBom ? Utf8Bom : [], lines); + } + + private static byte[] Join(byte[] bom, IEnumerable lines) + { + using var stream = new MemoryStream(); + stream.Write(bom); + var first = true; + foreach (var line in lines) + { + if (!first) stream.WriteByte((byte)'\n'); + stream.Write(line); + first = false; + } + return stream.ToArray(); + } + + private static bool StartsWith(byte[] line, byte[] prefix) => line.AsSpan().StartsWith(prefix); + + private static byte[]? DecodeBase64(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + try + { + return Convert.FromBase64String(value.Trim()); + } + catch (FormatException) + { + return null; + } + } +} diff --git a/src/Infrastructure/StartSet.Infrastructure.csproj b/src/Infrastructure/StartSet.Infrastructure.csproj index c871f4f..c4eb5d8 100644 --- a/src/Infrastructure/StartSet.Infrastructure.csproj +++ b/src/Infrastructure/StartSet.Infrastructure.csproj @@ -20,6 +20,7 @@ + diff --git a/tests/StartSet.Tests/Infrastructure/PolicyTemplateTests.cs b/tests/StartSet.Tests/Infrastructure/PolicyTemplateTests.cs index b0ef2b3..606f336 100644 --- a/tests/StartSet.Tests/Infrastructure/PolicyTemplateTests.cs +++ b/tests/StartSet.Tests/Infrastructure/PolicyTemplateTests.cs @@ -38,8 +38,8 @@ private static string RepoRoot() } /// - /// Values StartSet reads from policy only, which are not settings: they are not on the Prefs - /// tab and are ignored at every other level. Each is a REG_SZ text element under Security. + /// Values StartSet reads from policy only ([PolicyOnly] settings): ignored at every other + /// level and shown locked on the Prefs tab. Each is a REG_SZ text element under Security. /// public static readonly string[] PolicyOnlyValues = ["ManifestSigningKey"]; @@ -141,6 +141,11 @@ public void EverySetting_SetTheWayThePolicyWritesIt_IsManagedAndLocked(string na PrefsElevation.CanEdit(isElevated: true, isPolicyManaged: svc.Sources[name] == SettingSource.Policy).Should().BeFalse(); } + [Fact] + public void PolicyOnlyValues_AreThePolicyOnlySettings() => + PreferenceResolver.Settings.Where(s => s.PolicyOnly).Select(s => s.Name) + .Should().BeEquivalentTo(PolicyOnlyValues); + [Fact] public void PolicyOnlyValues_AreTextUnderSecurity() { diff --git a/tests/StartSet.Tests/Infrastructure/ScriptSigningTests.cs b/tests/StartSet.Tests/Infrastructure/ScriptSigningTests.cs new file mode 100644 index 0000000..7d5ad0e --- /dev/null +++ b/tests/StartSet.Tests/Infrastructure/ScriptSigningTests.cs @@ -0,0 +1,170 @@ +using System.Text; +using FluentAssertions; +using StartSet.Infrastructure.Security; + +namespace StartSet.Tests.Infrastructure; + +/// +/// The embedded Ed25519 signature format. The known-answer tests pin the format to RFC 8032 +/// test vectors, so any other implementation -- a CI signing step, outset on the Mac -- can +/// check itself against the same bytes. +/// +public class ScriptSigningTests +{ + // RFC 8032 section 7.1, TEST 1 (empty message) and TEST 2 (message 0x72). + private const string Rfc8032Test1Secret = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"; + private const string Rfc8032Test1Public = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"; + private const string Rfc8032Test1Signature = "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"; + private const string Rfc8032Test2Secret = "4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb"; + private const string Rfc8032Test2Public = "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"; + private const string Rfc8032Test2Signature = "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00"; + + private static string B64(string hex) => Convert.ToBase64String(Convert.FromHexString(hex)); + + private static readonly (string Public, string Private) Keys = ScriptSigning.GenerateKeypair(); + + private static byte[] Sign(string content, string path) => + ScriptSigning.Sign(Encoding.UTF8.GetBytes(content), path, ScriptSigning.ParsePrivateKey(Keys.Private)!); + + private static SignatureCheck Verify(byte[] content, string path, string? key = null) => + ScriptSigning.Verify(content, path, ScriptSigning.ParsePublicKey(key ?? Keys.Public)); + + // ──────────────── Known answers ──────────────── + + [Fact] + public void KnownAnswer_EmptyScript_SignsToRfc8032Test1() + { + var signed = ScriptSigning.Sign([], "empty.ps1", ScriptSigning.ParsePrivateKey(B64(Rfc8032Test1Secret))!); + + Encoding.UTF8.GetString(signed).Should().Be("# ed25519: " + B64(Rfc8032Test1Signature) + "\n"); + ScriptSigning.Verify(signed, "empty.ps1", ScriptSigning.ParsePublicKey(B64(Rfc8032Test1Public))).IsValid.Should().BeTrue(); + } + + [Fact] + public void KnownAnswer_OneByteScript_SignsToRfc8032Test2() + { + // The canonical payload of "r" is the single byte 0x72: TEST 2's message. + var signed = ScriptSigning.Sign("r"u8.ToArray(), "one.ps1", ScriptSigning.ParsePrivateKey(B64(Rfc8032Test2Secret))!); + + Encoding.UTF8.GetString(signed).Should().Be("# ed25519: " + B64(Rfc8032Test2Signature) + "\nr"); + ScriptSigning.Verify(signed, "one.ps1", ScriptSigning.ParsePublicKey(B64(Rfc8032Test2Public))).IsValid.Should().BeTrue(); + } + + [Fact] + public void KnownAnswer_BatchPrefixCoversTheSameBytes() + { + var signed = ScriptSigning.Sign("r"u8.ToArray(), "one.cmd", ScriptSigning.ParsePrivateKey(B64(Rfc8032Test2Secret))!); + + Encoding.UTF8.GetString(signed).Should().Be("REM ed25519: " + B64(Rfc8032Test2Signature) + "\nr"); + } + + // ──────────────── Round trips ──────────────── + + [Theory] + [InlineData("script.ps1", "# ed25519: ")] + [InlineData("script.cmd", "REM ed25519: ")] + [InlineData("script.bat", "REM ed25519: ")] + public void RoundTrip_EachScriptType(string path, string prefix) + { + var signed = Sign("Write-Output hi\nexit 0\n", path); + + Encoding.UTF8.GetString(signed).Should().StartWith(prefix); + Verify(signed, path).IsValid.Should().BeTrue(); + } + + [Fact] + public void RoundTrip_Crlf_KeepsCrlfAndVerifies() + { + var signed = Sign("@echo off\r\necho hi\r\n", "script.cmd"); + var text = Encoding.UTF8.GetString(signed); + + text.Should().MatchRegex("^REM ed25519: [A-Za-z0-9+/=]+\r\n@echo off\r\necho hi\r\n$"); + Verify(signed, "script.cmd").IsValid.Should().BeTrue(); + } + + [Fact] + public void RoundTrip_Bom_StaysFirstAndVerifies() + { + var content = new byte[] { 0xEF, 0xBB, 0xBF }.Concat("Write-Output hi\n"u8.ToArray()).ToArray(); + var signed = ScriptSigning.Sign(content, "script.ps1", ScriptSigning.ParsePrivateKey(Keys.Private)!); + + signed.Take(3).Should().Equal(0xEF, 0xBB, 0xBF); + Encoding.UTF8.GetString(signed, 3, signed.Length - 3).Should().StartWith("# ed25519: "); + Verify(signed, "script.ps1").IsValid.Should().BeTrue(); + } + + [Fact] + public void RoundTrip_Shebang_StaysFirstLine() + { + var signed = Encoding.UTF8.GetString(Sign("#!/usr/bin/env pwsh\nexit 0\n", "script.ps1")); + + signed.Split('\n')[0].Should().Be("#!/usr/bin/env pwsh"); + signed.Split('\n')[1].Should().StartWith("# ed25519: "); + } + + [Fact] + public void Resigning_ReplacesTheOldSignatureLine() + { + var once = Sign("exit 0\n", "script.ps1"); + var twice = ScriptSigning.Sign(once, "script.ps1", ScriptSigning.ParsePrivateKey(Keys.Private)!); + + Encoding.UTF8.GetString(twice).Split('\n').Count(l => l.StartsWith("# ed25519: ")).Should().Be(1); + Verify(twice, "script.ps1").IsValid.Should().BeTrue(); + } + + // ──────────────── Failures ──────────────── + + [Fact] + public void Tampered_FailsAsBadSignature() + { + var signed = Encoding.UTF8.GetString(Sign("exit 0\n", "script.ps1")).Replace("exit 0", "exit 1"); + + Verify(Encoding.UTF8.GetBytes(signed), "script.ps1").Failure.Should().Be(SignatureFailure.BadSignature); + } + + [Fact] + public void LineEndingChange_FailsAsBadSignature() + { + var signed = Encoding.UTF8.GetString(Sign("a\nb\n", "script.ps1")).Replace("a\nb", "a\r\nb"); + + Verify(Encoding.UTF8.GetBytes(signed), "script.ps1").Failure.Should().Be(SignatureFailure.BadSignature); + } + + [Fact] + public void OtherKey_FailsAsBadSignature() + { + var signed = Sign("exit 0\n", "script.ps1"); + + Verify(signed, "script.ps1", ScriptSigning.GenerateKeypair().PublicKey).Failure.Should().Be(SignatureFailure.BadSignature); + } + + [Fact] + public void Unsigned_FailsAsNoSignature() => + Verify("exit 0\n"u8.ToArray(), "script.ps1").Failure.Should().Be(SignatureFailure.NoSignature); + + [Fact] + public void PowerShellPrefixInBatchFile_DoesNotCount() + { + var signed = Sign("exit 0\n", "script.ps1"); + + Verify(signed, "script.cmd").Failure.Should().Be(SignatureFailure.NoSignature); + } + + [Fact] + public void GarbageSignature_FailsAsMalformed() => + Verify("# ed25519: not-base64!\nexit 0\n"u8.ToArray(), "script.ps1").Failure.Should().Be(SignatureFailure.MalformedSignature); + + [Theory] + [InlineData("tool.exe")] + [InlineData("app.msi")] + [InlineData("app.msix")] + public void BinaryPayloads_CannotCarryASignature(string path) => + Verify([1, 2, 3], path).Failure.Should().Be(SignatureFailure.UnsupportedType); + + [Theory] + [InlineData("not base64")] + [InlineData("AAAA")] + public void BadPublicKey_FailsAsBadKey(string key) => + ScriptSigning.Verify(Sign("exit 0\n", "script.ps1"), "script.ps1", ScriptSigning.ParsePublicKey(key)) + .Failure.Should().Be(SignatureFailure.BadKey); +} diff --git a/tests/StartSet.Tests/Integration/ScriptSigningEnforcementTests.cs b/tests/StartSet.Tests/Integration/ScriptSigningEnforcementTests.cs new file mode 100644 index 0000000..d8914e3 --- /dev/null +++ b/tests/StartSet.Tests/Integration/ScriptSigningEnforcementTests.cs @@ -0,0 +1,170 @@ +using System.Text; +using FluentAssertions; +using StartSet.Core.Enums; +using StartSet.Engine; +using StartSet.Infrastructure.Configuration; +using StartSet.Infrastructure.Security; +using StartSet.Tests.Helpers; + +namespace StartSet.Tests.Integration; + +/// +/// Required script signing end to end: policy sets the key, discovery verifies each payload, +/// and a payload that fails is refused and reported rather than run or quietly skipped. +/// +public class ScriptSigningEnforcementTests : IDisposable +{ + private readonly TempDirectory _temp = new(); + private readonly (string Public, string Private) _keys = ScriptSigning.GenerateKeypair(); + + public void Dispose() => _temp.Dispose(); + + private PreferencesService Service(InMemorySettingsStore? policy = null, InMemorySettingsStore? machine = null, string yaml = "") + { + var service = TestSettings.Service(policy: policy, machine: machine); + service.Load(_temp.CreateFile("Config.yaml", yaml)); + return service; + } + + private PreferencesService Enforced(string? key = null) => + Service(new InMemorySettingsStore().With("ManifestSigningKey", key ?? _keys.Public)); + + private void Payload(string name, string content) => + _temp.CreateFile(Path.Combine("boot-every", name), content); + + private void SignedPayload(string name, string content) + { + var path = Path.Combine("boot-every", name); + var signed = ScriptSigning.Sign(Encoding.UTF8.GetBytes(content), name, ScriptSigning.ParsePrivateKey(_keys.Private)!); + _temp.CreateFile(path, Encoding.UTF8.GetString(signed)); + } + + private Task> Run(PreferencesService service) => + new ExecutionEngine(service, _temp.Path, TestSettings.TrustAll).ExecuteAsync([PayloadType.BootEvery], waitForNetwork: false); + + [Fact] + public async Task Enforced_UnsignedScript_IsRefusedAndReported() + { + Payload("Unsigned.ps1", "exit 0"); + + var results = await Run(Enforced()); + + results.Should().ContainSingle(); + results[0].Status.Should().Be(ExecutionStatus.SignatureRejected); + results[0].ErrorMessage.Should().Contain("no signature"); + } + + [Fact] + public async Task Enforced_SignedScript_IsNotRefused() + { + // No assertion that it succeeded: boot-every needs elevation, which the test + // runner may not have. The claim is only that signing let it through. + SignedPayload("Signed.ps1", "exit 0\n"); + + var results = await Run(Enforced()); + + results.Should().ContainSingle(); + results[0].Status.Should().NotBe(ExecutionStatus.SignatureRejected); + } + + [Fact] + public async Task Enforced_TamperedScript_IsRefused() + { + var signed = ScriptSigning.Sign("exit 0\n"u8.ToArray(), "Tampered.ps1", ScriptSigning.ParsePrivateKey(_keys.Private)!); + Payload("Tampered.ps1", Encoding.UTF8.GetString(signed).Replace("exit 0", "exit 1")); + + var results = await Run(Enforced()); + + results.Single().Status.Should().Be(ExecutionStatus.SignatureRejected); + results.Single().ErrorMessage.Should().Contain("bad signature"); + } + + [Fact] + public async Task Enforced_Executable_IsRefused() + { + Payload("Tool.exe", "MZ"); + + var results = await Run(Enforced()); + + results.Single().Status.Should().Be(ExecutionStatus.SignatureRejected); + results.Single().ErrorMessage.Should().Contain("cannot carry"); + } + + [Fact] + public async Task Enforced_InvalidPolicyKey_RefusesEverything() + { + SignedPayload("Signed.ps1", "exit 0\n"); + + var results = await Run(Enforced("not-a-key")); + + results.Single().Status.Should().Be(ExecutionStatus.SignatureRejected); + results.Single().ErrorMessage.Should().Contain("bad key"); + } + + [Fact] + public async Task NotEnforced_UnsignedScript_RunsAsBefore() + { + Payload("Unsigned.ps1", "exit 0"); + + var results = await Run(Service()); + + results.Single().Status.Should().NotBe(ExecutionStatus.SignatureRejected); + } + + [Fact] + public async Task Enforced_NestedFolder_StillNotDiscovered() + { + SignedPayload("Signed.ps1", "exit 0\n"); + _temp.CreateFile(Path.Combine("boot-every", "nested", "Inner.ps1"), "exit 0"); + + var results = await Run(Enforced()); + + results.Select(r => r.Script.FileName).Should().Equal("Signed.ps1"); + } + + [Fact] + public void KeyFromMachineSettings_IsIgnored() + { + var service = Service(machine: new InMemorySettingsStore().With("ManifestSigningKey", _keys.Public)); + + service.Preferences.ManifestSigningKey.Should().BeNull(); + service.Sources["ManifestSigningKey"].Should().Be(SettingSource.Default); + } + + [Fact] + public void KeyFromConfigYaml_IsIgnored() + { + var service = Service(yaml: $"manifest_signing_key: {_keys.Public}"); + + service.Preferences.ManifestSigningKey.Should().BeNull(); + } + + [Fact] + public void KeyFromCommandLine_IsIgnored() + { + var service = TestSettings.Service(); + service.SetCommandLineOverrides(new Dictionary { ["ManifestSigningKey"] = _keys.Public }); + service.Load(_temp.CreateFile("Config.yaml", "")); + + service.Preferences.ManifestSigningKey.Should().BeNull(); + } + + [Fact] + public void KeyFromPolicy_IsUsedAndManaged() + { + var service = Enforced(); + + service.Preferences.ManifestSigningKey.Should().Be(_keys.Public); + service.IsManaged("ManifestSigningKey").Should().BeTrue(); + } + + [Fact] + public void KeyCannotBeSavedToMachineSettings() + { + var service = Service(); + + var save = () => service.SaveMachineSetting("ManifestSigningKey", _keys.Public); + + save.Should().Throw().WithMessage("*only be set by policy*"); + } +}