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
40 changes: 39 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <base64>
managedstatekeeper generate-keypair

# Show version
managedstatekeeper --version
```
Expand All @@ -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 |
|---|---|---|
Expand All @@ -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.

Expand All @@ -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\<timestamp>\` 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: <base64>` in a `.ps1`, `REM ed25519: <base64>` 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 <base64>
```

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:
Expand Down
5 changes: 3 additions & 2 deletions resources/StartSet.admx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
-->
<policyDefinitions xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
Expand Down
6 changes: 3 additions & 3 deletions resources/en-US/StartSet.adml
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,11 @@ Disabled: off.

Writes LogScriptOutput (REG_DWORD 1 or 0).</string>
<string id="ManifestSigningKey">Manifest signing public key</string>
<string id="ManifestSigningKey_Help">The public key StartSet uses to verify signed script manifests: the base64 encoding of a raw 32-byte Ed25519 public key (44 characters).
<string id="ManifestSigningKey_Help">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).</string>
</stringTable>
Expand Down
2 changes: 1 addition & 1 deletion src/App/ViewModels/PrefsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion src/CLI/Commands/BootCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
2 changes: 1 addition & 1 deletion src/CLI/Commands/LoginCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
2 changes: 1 addition & 1 deletion src/CLI/Commands/LoginPrivilegedCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
2 changes: 1 addition & 1 deletion src/CLI/Commands/LoginWindowCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});

Expand Down
2 changes: 1 addition & 1 deletion src/CLI/Commands/OnDemandCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
2 changes: 1 addition & 1 deletion src/CLI/Commands/ProcessingCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
158 changes: 158 additions & 0 deletions src/CLI/Commands/SigningCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
using System.CommandLine;
using StartSet.Infrastructure.Configuration;
using StartSet.Infrastructure.Security;

namespace StartSet.CLI.Commands;

/// <summary>
/// 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.
/// </summary>
public static class SigningCommand
{
public const string DefaultKeyVariable = "STARTSET_SIGNING_KEY";

/// <summary>The command names, which run without a session log.</summary>
public static readonly HashSet<string> Names = new(StringComparer.OrdinalIgnoreCase) { "sign", "verify", "generate-keypair" };

/// <summary>
/// startset sign &lt;files...&gt; [--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.
/// </summary>
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<string[]>("files", "Scripts to sign") { Arity = ArgumentArity.OneOrMore };
var keyEnvOption = new Option<string>(
aliases: ["--key-env"],
getDefaultValue: () => DefaultKeyVariable,
description: "Environment variable holding the base64 private key");
var keyFileOption = new Option<string?>(
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;
}

/// <summary>startset verify &lt;files...&gt; [--public-key B64]. Exits 1 when any file fails.</summary>
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<string[]>("files", "Scripts to verify") { Arity = ArgumentArity.OneOrMore };
var publicKeyOption = new Option<string?>(
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;
}

/// <summary>
/// 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.
/// </summary>
internal static string Resolve(string file) =>
Path.GetFullPath(file, Environment.CurrentDirectory);

/// <summary>startset generate-keypair. Prints a new public and private key.</summary>
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;
}
}
19 changes: 19 additions & 0 deletions src/CLI/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,20 @@ public static async Task<int> 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";
Expand Down Expand Up @@ -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;
}
}
10 changes: 9 additions & 1 deletion src/Core/Enums/ExecutionStatus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,13 @@ public enum ExecutionStatus
/// of problem invisible: a login payload that silently did nothing looked
/// exactly like one that worked.
/// </summary>
Deferred
Deferred,

/// <summary>
/// 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.
/// </summary>
SignatureRejected
}
11 changes: 11 additions & 0 deletions src/Core/Models/ExecutionResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,17 @@ public class ExecutionResult
StartTime = DateTimeOffset.UtcNow
};

/// <summary>
/// Creates a result for a payload refused because its signature did not verify.
/// </summary>
public static ExecutionResult SignatureRejected(ScriptPayload script, string reason) => new()
{
Script = script,
Status = ExecutionStatus.SignatureRejected,
ErrorMessage = $"Refused: {reason}",
StartTime = DateTimeOffset.UtcNow
};

/// <summary>
/// Creates a timeout result.
/// </summary>
Expand Down
15 changes: 15 additions & 0 deletions src/Core/Models/PolicyOnlyAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace StartSet.Core.Models;

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[AttributeUsage(AttributeTargets.Property)]
public sealed class PolicyOnlyAttribute : Attribute
{
}
Loading
Loading