feat(kms): Implement file-backed key management commands and handlers
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Added `kms export` and `kms import` commands to manage file-backed signing keys. - Implemented `HandleKmsExportAsync` and `HandleKmsImportAsync` methods in CommandHandlers for exporting and importing key material. - Introduced KmsPassphrasePrompt for secure passphrase input. - Updated CLI architecture documentation to include new KMS commands. - Enhanced unit tests for KMS export and import functionalities. - Updated project references to include StellaOps.Cryptography.Kms library. - Marked KMS interface implementation and CLI support tasks as DONE in the task board.
This commit is contained in:
@@ -38,6 +38,7 @@ internal static class CommandFactory
|
||||
root.Add(BuildPolicyCommand(services, options, verboseOption, cancellationToken));
|
||||
root.Add(BuildFindingsCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildConfigCommand(options));
|
||||
root.Add(BuildKmsCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildVulnCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
var pluginLogger = loggerFactory.CreateLogger<CliCommandModuleLoader>();
|
||||
@@ -92,9 +93,9 @@ internal static class CommandFactory
|
||||
return scanner;
|
||||
}
|
||||
|
||||
private static Command BuildScanCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var scan = new Command("scan", "Execute scanners and manage scan outputs.");
|
||||
private static Command BuildScanCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var scan = new Command("scan", "Execute scanners and manage scan outputs.");
|
||||
|
||||
var run = new Command("run", "Execute a scanner bundle with the configured runner.");
|
||||
var runnerOption = new Option<string>("--runner")
|
||||
@@ -148,10 +149,126 @@ internal static class CommandFactory
|
||||
});
|
||||
|
||||
scan.Add(run);
|
||||
scan.Add(upload);
|
||||
return scan;
|
||||
}
|
||||
|
||||
scan.Add(upload);
|
||||
return scan;
|
||||
}
|
||||
|
||||
private static Command BuildKmsCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var kms = new Command("kms", "Manage file-backed signing keys.");
|
||||
|
||||
var export = new Command("export", "Export key material to a portable bundle.");
|
||||
var exportRootOption = new Option<string>("--root")
|
||||
{
|
||||
Description = "Root directory containing file-based KMS material."
|
||||
};
|
||||
var exportKeyOption = new Option<string>("--key-id")
|
||||
{
|
||||
Description = "Logical KMS key identifier to export.",
|
||||
Required = true
|
||||
};
|
||||
var exportVersionOption = new Option<string?>("--version")
|
||||
{
|
||||
Description = "Key version identifier to export (defaults to active version)."
|
||||
};
|
||||
var exportOutputOption = new Option<string>("--output")
|
||||
{
|
||||
Description = "Destination file path for exported key material.",
|
||||
Required = true
|
||||
};
|
||||
var exportForceOption = new Option<bool>("--force")
|
||||
{
|
||||
Description = "Overwrite the destination file if it already exists."
|
||||
};
|
||||
var exportPassphraseOption = new Option<string?>("--passphrase")
|
||||
{
|
||||
Description = "File KMS passphrase (falls back to STELLAOPS_KMS_PASSPHRASE or interactive prompt)."
|
||||
};
|
||||
|
||||
export.Add(exportRootOption);
|
||||
export.Add(exportKeyOption);
|
||||
export.Add(exportVersionOption);
|
||||
export.Add(exportOutputOption);
|
||||
export.Add(exportForceOption);
|
||||
export.Add(exportPassphraseOption);
|
||||
|
||||
export.SetAction((parseResult, _) =>
|
||||
{
|
||||
var root = parseResult.GetValue(exportRootOption);
|
||||
var keyId = parseResult.GetValue(exportKeyOption) ?? string.Empty;
|
||||
var versionId = parseResult.GetValue(exportVersionOption);
|
||||
var output = parseResult.GetValue(exportOutputOption) ?? string.Empty;
|
||||
var force = parseResult.GetValue(exportForceOption);
|
||||
var passphrase = parseResult.GetValue(exportPassphraseOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleKmsExportAsync(
|
||||
services,
|
||||
root,
|
||||
keyId,
|
||||
versionId,
|
||||
output,
|
||||
force,
|
||||
passphrase,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
var import = new Command("import", "Import key material from a bundle.");
|
||||
var importRootOption = new Option<string>("--root")
|
||||
{
|
||||
Description = "Root directory containing file-based KMS material."
|
||||
};
|
||||
var importKeyOption = new Option<string>("--key-id")
|
||||
{
|
||||
Description = "Logical KMS key identifier to import into.",
|
||||
Required = true
|
||||
};
|
||||
var importInputOption = new Option<string>("--input")
|
||||
{
|
||||
Description = "Path to exported key material JSON.",
|
||||
Required = true
|
||||
};
|
||||
var importVersionOption = new Option<string?>("--version")
|
||||
{
|
||||
Description = "Override the imported version identifier."
|
||||
};
|
||||
var importPassphraseOption = new Option<string?>("--passphrase")
|
||||
{
|
||||
Description = "File KMS passphrase (falls back to STELLAOPS_KMS_PASSPHRASE or interactive prompt)."
|
||||
};
|
||||
|
||||
import.Add(importRootOption);
|
||||
import.Add(importKeyOption);
|
||||
import.Add(importInputOption);
|
||||
import.Add(importVersionOption);
|
||||
import.Add(importPassphraseOption);
|
||||
|
||||
import.SetAction((parseResult, _) =>
|
||||
{
|
||||
var root = parseResult.GetValue(importRootOption);
|
||||
var keyId = parseResult.GetValue(importKeyOption) ?? string.Empty;
|
||||
var input = parseResult.GetValue(importInputOption) ?? string.Empty;
|
||||
var versionOverride = parseResult.GetValue(importVersionOption);
|
||||
var passphrase = parseResult.GetValue(importPassphraseOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleKmsImportAsync(
|
||||
services,
|
||||
root,
|
||||
keyId,
|
||||
input,
|
||||
versionOverride,
|
||||
passphrase,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
kms.Add(export);
|
||||
kms.Add(import);
|
||||
return kms;
|
||||
}
|
||||
|
||||
private static Command BuildDatabaseCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var db = new Command("db", "Trigger Concelier database operations via backend jobs.");
|
||||
|
||||
@@ -22,19 +22,26 @@ using Spectre.Console;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Prompts;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Kms;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
internal static class CommandHandlers
|
||||
{
|
||||
public static async Task HandleScannerDownloadAsync(
|
||||
IServiceProvider services,
|
||||
string channel,
|
||||
string? output,
|
||||
internal static class CommandHandlers
|
||||
{
|
||||
private const string KmsPassphraseEnvironmentVariable = "STELLAOPS_KMS_PASSPHRASE";
|
||||
private static readonly JsonSerializerOptions KmsJsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public static async Task HandleScannerDownloadAsync(
|
||||
IServiceProvider services,
|
||||
string channel,
|
||||
string? output,
|
||||
bool overwrite,
|
||||
bool install,
|
||||
bool verbose,
|
||||
@@ -5536,7 +5543,187 @@ internal static class CommandHandlers
|
||||
return Encoding.UTF8;
|
||||
}
|
||||
|
||||
private static bool TryDecodeBase64(string text, out byte[] decoded)
|
||||
public static async Task HandleKmsExportAsync(
|
||||
IServiceProvider services,
|
||||
string? rootPath,
|
||||
string keyId,
|
||||
string? versionId,
|
||||
string outputPath,
|
||||
bool overwrite,
|
||||
string? passphrase,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("kms-export");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
|
||||
try
|
||||
{
|
||||
var resolvedPassphrase = ResolvePassphrase(passphrase, "Enter file KMS passphrase:");
|
||||
if (string.IsNullOrEmpty(resolvedPassphrase))
|
||||
{
|
||||
logger.LogError("KMS passphrase must be supplied via --passphrase, {EnvironmentVariable}, or interactive prompt.", KmsPassphraseEnvironmentVariable);
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var resolvedRoot = ResolveRootDirectory(rootPath);
|
||||
if (!Directory.Exists(resolvedRoot))
|
||||
{
|
||||
logger.LogError("KMS root directory '{Root}' does not exist.", resolvedRoot);
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var outputFullPath = Path.GetFullPath(string.IsNullOrWhiteSpace(outputPath) ? "kms-export.json" : outputPath);
|
||||
if (Directory.Exists(outputFullPath))
|
||||
{
|
||||
logger.LogError("Output path '{Output}' is a directory. Provide a file path.", outputFullPath);
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!overwrite && File.Exists(outputFullPath))
|
||||
{
|
||||
logger.LogError("Output file '{Output}' already exists. Use --force to overwrite.", outputFullPath);
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var outputDirectory = Path.GetDirectoryName(outputFullPath);
|
||||
if (!string.IsNullOrEmpty(outputDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
}
|
||||
|
||||
using var client = new FileKmsClient(new FileKmsOptions
|
||||
{
|
||||
RootPath = resolvedRoot,
|
||||
Password = resolvedPassphrase!
|
||||
});
|
||||
|
||||
var material = await client.ExportAsync(keyId, versionId, cancellationToken).ConfigureAwait(false);
|
||||
var json = JsonSerializer.Serialize(material, KmsJsonOptions);
|
||||
await File.WriteAllTextAsync(outputFullPath, json, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
logger.LogInformation("Exported key {KeyId} version {VersionId} to {Output}.", material.KeyId, material.VersionId, outputFullPath);
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to export key material.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleKmsImportAsync(
|
||||
IServiceProvider services,
|
||||
string? rootPath,
|
||||
string keyId,
|
||||
string inputPath,
|
||||
string? versionOverride,
|
||||
string? passphrase,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("kms-import");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
|
||||
try
|
||||
{
|
||||
var resolvedPassphrase = ResolvePassphrase(passphrase, "Enter file KMS passphrase:");
|
||||
if (string.IsNullOrEmpty(resolvedPassphrase))
|
||||
{
|
||||
logger.LogError("KMS passphrase must be supplied via --passphrase, {EnvironmentVariable}, or interactive prompt.", KmsPassphraseEnvironmentVariable);
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var resolvedRoot = ResolveRootDirectory(rootPath);
|
||||
Directory.CreateDirectory(resolvedRoot);
|
||||
|
||||
var inputFullPath = Path.GetFullPath(inputPath ?? string.Empty);
|
||||
if (!File.Exists(inputFullPath))
|
||||
{
|
||||
logger.LogError("Input file '{Input}' does not exist.", inputFullPath);
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var json = await File.ReadAllTextAsync(inputFullPath, cancellationToken).ConfigureAwait(false);
|
||||
var material = JsonSerializer.Deserialize<KmsKeyMaterial>(json, KmsJsonOptions)
|
||||
?? throw new InvalidOperationException("Key material payload is empty.");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(versionOverride))
|
||||
{
|
||||
material = material with { VersionId = versionOverride };
|
||||
}
|
||||
|
||||
var sourceKeyId = material.KeyId;
|
||||
material = material with { KeyId = keyId };
|
||||
|
||||
using var client = new FileKmsClient(new FileKmsOptions
|
||||
{
|
||||
RootPath = resolvedRoot,
|
||||
Password = resolvedPassphrase!
|
||||
});
|
||||
|
||||
var metadata = await client.ImportAsync(keyId, material, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(sourceKeyId) && !string.Equals(sourceKeyId, keyId, StringComparison.Ordinal))
|
||||
{
|
||||
logger.LogWarning("Imported key material originally identified as '{SourceKeyId}' into '{TargetKeyId}'.", sourceKeyId, keyId);
|
||||
}
|
||||
|
||||
var activeVersion = metadata.Versions.Length > 0 ? metadata.Versions[^1].VersionId : material.VersionId;
|
||||
logger.LogInformation("Imported key {KeyId} version {VersionId} into {Root}.", metadata.KeyId, activeVersion, resolvedRoot);
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to parse key material JSON from {Input}.", inputPath);
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to import key material.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveRootDirectory(string? rootPath)
|
||||
=> Path.GetFullPath(string.IsNullOrWhiteSpace(rootPath) ? "kms" : rootPath);
|
||||
|
||||
private static string? ResolvePassphrase(string? passphrase, string promptMessage)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(passphrase))
|
||||
{
|
||||
return passphrase;
|
||||
}
|
||||
|
||||
var fromEnvironment = Environment.GetEnvironmentVariable(KmsPassphraseEnvironmentVariable);
|
||||
if (!string.IsNullOrWhiteSpace(fromEnvironment))
|
||||
{
|
||||
return fromEnvironment;
|
||||
}
|
||||
|
||||
return KmsPassphrasePrompt.Prompt(promptMessage);
|
||||
}
|
||||
|
||||
private static bool TryDecodeBase64(string text, out byte[] decoded)
|
||||
{
|
||||
decoded = Array.Empty<byte>();
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
|
||||
Reference in New Issue
Block a user