feat(kms): Implement file-backed key management commands and handlers
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:
master
2025-10-30 14:41:48 +02:00
parent a3822c88cd
commit 240e8ff25d
13 changed files with 697 additions and 107 deletions

View File

@@ -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))