From 240e8ff25ddf0f3385b817c30c26a24bff2e5730 Mon Sep 17 00:00:00 2001
From: master <>
Date: Thu, 30 Oct 2025 14:41:48 +0200
Subject: [PATCH] feat(kms): Implement file-backed key management commands and
handlers
- 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.
---
NuGet.config | 11 +-
docs/implplan/SPRINTS.md | 4 +-
docs/modules/cli/architecture.md | 34 ++-
.../StellaOps.Cli/Commands/CommandFactory.cs | 131 ++++++++++-
.../StellaOps.Cli/Commands/CommandHandlers.cs | 209 +++++++++++++++++-
.../Prompts/KmsPassphrasePrompt.cs | 29 +++
src/Cli/StellaOps.Cli/StellaOps.Cli.csproj | 4 +-
.../StellaOps.Cli.Plugins.NonCore.csproj | 2 +-
.../Commands/CommandHandlersTests.cs | 198 +++++++++++++----
.../Plugins/CliCommandModuleLoaderTests.cs | 69 ++++--
.../StellaOps.Cli.Tests.csproj | 3 +-
.../FileKmsClient.cs | 106 +++++++++
.../StellaOps.Cryptography.Kms/TASKS.md | 4 +-
13 files changed, 697 insertions(+), 107 deletions(-)
create mode 100644 src/Cli/StellaOps.Cli/Prompts/KmsPassphrasePrompt.cs
diff --git a/NuGet.config b/NuGet.config
index 8dcc61ea..0706b670 100644
--- a/NuGet.config
+++ b/NuGet.config
@@ -34,11 +34,12 @@
-
-
-
-
-
+
+
+
+
+
+
diff --git a/docs/implplan/SPRINTS.md b/docs/implplan/SPRINTS.md
index 89670078..1e89c0c4 100644
--- a/docs/implplan/SPRINTS.md
+++ b/docs/implplan/SPRINTS.md
@@ -1055,7 +1055,7 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation
| Sprint 72 | Attestor Console Phase 1 – Foundations | src/Attestor/StellaOps.Attestor.Types/TASKS.md | TODO | Attestation Payloads Guild | ATTEST-TYPES-72-002 | Generate models/validators from schemas. |
| Sprint 72 | Attestor Console Phase 1 – Foundations | src/Attestor/StellaOps.Attestor/TASKS.md | TODO | Attestor Service Guild | ATTESTOR-72-001 | Scaffold attestor service skeleton. |
| Sprint 72 | Attestor Console Phase 1 – Foundations | src/Attestor/StellaOps.Attestor/TASKS.md | TODO | Attestor Service Guild | ATTESTOR-72-002 | Implement attestation store + storage integration. |
-| Sprint 72 | Attestor Console Phase 1 – Foundations | src/__Libraries/StellaOps.Cryptography.Kms/TASKS.md | TODO | KMS Guild | KMS-72-001 | Implement KMS interface + file driver. |
+| Sprint 72 | Attestor Console Phase 1 – Foundations | src/__Libraries/StellaOps.Cryptography.Kms/TASKS.md | DONE | KMS Guild | KMS-72-001 | Implement KMS interface + file driver. |
| Sprint 73 | Attestor CLI Phase 2 – Signing & Policies | src/Cli/StellaOps.Cli/TASKS.md | TODO | CLI Attestor Guild | CLI-ATTEST-73-001 | Implement `stella attest sign` (payload selection, subject digest, key reference, output format) using official SDK transport. |
| Sprint 73 | Attestor CLI Phase 2 – Signing & Policies | src/Cli/StellaOps.Cli/TASKS.md | TODO | CLI Attestor Guild | CLI-ATTEST-73-002 | Implement `stella attest verify` with policy selection, explainability output, and JSON/table formatting. |
| Sprint 73 | Attestor Console Phase 2 – Signing & Policies | docs/TASKS.md | TODO | Docs Guild | DOCS-ATTEST-73-001 | Publish attestor overview. |
@@ -1067,7 +1067,7 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation
| Sprint 73 | Attestor Console Phase 2 – Signing & Policies | src/Attestor/StellaOps.Attestor/TASKS.md | TODO | Attestor Service Guild | ATTESTOR-73-001 | Ship signing endpoint. |
| Sprint 73 | Attestor Console Phase 2 – Signing & Policies | src/Attestor/StellaOps.Attestor/TASKS.md | TODO | Attestor Service Guild | ATTESTOR-73-002 | Ship verification pipeline and reports. |
| Sprint 73 | Attestor Console Phase 2 – Signing & Policies | src/Attestor/StellaOps.Attestor/TASKS.md | TODO | Attestor Service Guild | ATTESTOR-73-003 | Implement list/fetch APIs. |
-| Sprint 73 | Attestor Console Phase 2 – Signing & Policies | src/__Libraries/StellaOps.Cryptography.Kms/TASKS.md | TODO | KMS Guild | KMS-72-002 | CLI support for key import/export. |
+| Sprint 73 | Attestor Console Phase 2 – Signing & Policies | src/__Libraries/StellaOps.Cryptography.Kms/TASKS.md | DONE (2025-10-30) | KMS Guild | KMS-72-002 | CLI support for key import/export. |
| Sprint 73 | Attestor Console Phase 2 – Signing & Policies | src/Policy/StellaOps.Policy.Engine/TASKS.md | TODO | Policy Guild | POLICY-ATTEST-73-001 | Implement VerificationPolicy lifecycle. |
| Sprint 73 | Attestor Console Phase 2 – Signing & Policies | src/Policy/StellaOps.Policy.Engine/TASKS.md | TODO | Policy Guild | POLICY-ATTEST-73-002 | Surface policies in Policy Studio. |
| Sprint 74 | Attestor CLI Phase 3 – Transparency & Chain of Custody | src/Cli/StellaOps.Cli/TASKS.md | TODO | CLI Attestor Guild | CLI-ATTEST-74-001 | Implement `stella attest list` with filters (subject, type, issuer, scope) and pagination. |
diff --git a/docs/modules/cli/architecture.md b/docs/modules/cli/architecture.md
index 4caacc4b..0902fc32 100644
--- a/docs/modules/cli/architecture.md
+++ b/docs/modules/cli/architecture.md
@@ -105,17 +105,29 @@ src/
* `whoami` — short auth display.
* `version` — CLI + protocol versions; release channel.
-### 2.9 Aggregation-only guard helpers
-
-* `sources ingest --dry-run --source --input [--tenant ... --format table|json --output file]`
-
- * Normalises documents (handles gzip/base64), posts them to the backend `aoc/ingest/dry-run` route, and exits non-zero when guard violations are detected.
- * Defaults to table output with ANSI colour; `--json`/`--output` produce deterministic JSON for CI pipelines.
-
-* `aoc verify [--since ] [--limit ] [--sources list] [--codes list] [--format table|json] [--export file] [--tenant id] [--no-color]`
-
- * Replays guard checks against stored raw documents. Maps backend `ERR_AOC_00x` codes onto deterministic exit codes so CI can block regressions.
- * Supports pagination hints (`--limit`, `--since`), tenant scoping via `--tenant` or `STELLA_TENANT`, and JSON exports for evidence lockers.
+### 2.9 Aggregation-only guard helpers
+
+* `sources ingest --dry-run --source --input [--tenant ... --format table|json --output file]`
+
+ * Normalises documents (handles gzip/base64), posts them to the backend `aoc/ingest/dry-run` route, and exits non-zero when guard violations are detected.
+ * Defaults to table output with ANSI colour; `--json`/`--output` produce deterministic JSON for CI pipelines.
+
+* `aoc verify [--since ] [--limit ] [--sources list] [--codes list] [--format table|json] [--export file] [--tenant id] [--no-color]`
+
+ * Replays guard checks against stored raw documents. Maps backend `ERR_AOC_00x` codes onto deterministic exit codes so CI can block regressions.
+ * Supports pagination hints (`--limit`, `--since`), tenant scoping via `--tenant` or `STELLA_TENANT`, and JSON exports for evidence lockers.
+
+### 2.10 Key management (file KMS support)
+
+* `kms export --key-id --output [--version ] [--force]`
+
+ * Decrypts the file-backed KMS store (passphrase supplied via `--passphrase`, `STELLAOPS_KMS_PASSPHRASE`, or interactive prompt) and writes a portable JSON bundle (`KmsKeyMaterial`) with key metadata and coordinates for offline escrow or replication.
+
+* `kms import --key-id --input [--version ]`
+
+ * Imports a previously exported bundle into the local KMS root (`kms/` by default), promotes the imported version to `Active`, and preserves existing versions by marking them `PendingRotation`. Prompts for the passphrase when not provided to keep automation password-safe.
+
+Both subcommands honour offline-first expectations (no network access) and normalise relative roots via `--root` when operators mirror the credential store.
---
diff --git a/src/Cli/StellaOps.Cli/Commands/CommandFactory.cs b/src/Cli/StellaOps.Cli/Commands/CommandFactory.cs
index 1e5d018c..0653bce1 100644
--- a/src/Cli/StellaOps.Cli/Commands/CommandFactory.cs
+++ b/src/Cli/StellaOps.Cli/Commands/CommandFactory.cs
@@ -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();
@@ -92,9 +93,9 @@ internal static class CommandFactory
return scanner;
}
- private static Command BuildScanCommand(IServiceProvider services, StellaOpsCliOptions options, Option verboseOption, CancellationToken cancellationToken)
- {
- var scan = new Command("scan", "Execute scanners and manage scan outputs.");
+ private static Command BuildScanCommand(IServiceProvider services, StellaOpsCliOptions options, Option 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("--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 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("--root")
+ {
+ Description = "Root directory containing file-based KMS material."
+ };
+ var exportKeyOption = new Option("--key-id")
+ {
+ Description = "Logical KMS key identifier to export.",
+ Required = true
+ };
+ var exportVersionOption = new Option("--version")
+ {
+ Description = "Key version identifier to export (defaults to active version)."
+ };
+ var exportOutputOption = new Option("--output")
+ {
+ Description = "Destination file path for exported key material.",
+ Required = true
+ };
+ var exportForceOption = new Option("--force")
+ {
+ Description = "Overwrite the destination file if it already exists."
+ };
+ var exportPassphraseOption = new Option("--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("--root")
+ {
+ Description = "Root directory containing file-based KMS material."
+ };
+ var importKeyOption = new Option("--key-id")
+ {
+ Description = "Logical KMS key identifier to import into.",
+ Required = true
+ };
+ var importInputOption = new Option("--input")
+ {
+ Description = "Path to exported key material JSON.",
+ Required = true
+ };
+ var importVersionOption = new Option("--version")
+ {
+ Description = "Override the imported version identifier."
+ };
+ var importPassphraseOption = new Option("--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 verboseOption, CancellationToken cancellationToken)
{
var db = new Command("db", "Trigger Concelier database operations via backend jobs.");
diff --git a/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs b/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs
index 2a2bf549..457c22b9 100644
--- a/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs
+++ b/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs
@@ -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().CreateLogger("kms-export");
+ var verbosity = scope.ServiceProvider.GetRequiredService();
+ 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().CreateLogger("kms-import");
+ var verbosity = scope.ServiceProvider.GetRequiredService();
+ 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(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();
if (string.IsNullOrWhiteSpace(text))
diff --git a/src/Cli/StellaOps.Cli/Prompts/KmsPassphrasePrompt.cs b/src/Cli/StellaOps.Cli/Prompts/KmsPassphrasePrompt.cs
new file mode 100644
index 00000000..430239fc
--- /dev/null
+++ b/src/Cli/StellaOps.Cli/Prompts/KmsPassphrasePrompt.cs
@@ -0,0 +1,29 @@
+using Spectre.Console;
+
+namespace StellaOps.Cli.Prompts;
+
+internal static class KmsPassphrasePrompt
+{
+ public static string? Prompt(string message)
+ {
+ if (!AnsiConsole.Profile.Capabilities.Interactive)
+ {
+ return null;
+ }
+
+ try
+ {
+ return AnsiConsole.Prompt(
+ new TextPrompt(message)
+ .Secret()
+ .PromptStyle("cyan1")
+ .Validate(value => string.IsNullOrWhiteSpace(value)
+ ? ValidationResult.Error("Passphrase cannot be empty.")
+ : ValidationResult.Success()));
+ }
+ catch
+ {
+ return null;
+ }
+ }
+}
diff --git a/src/Cli/StellaOps.Cli/StellaOps.Cli.csproj b/src/Cli/StellaOps.Cli/StellaOps.Cli.csproj
index bd23bf1e..04dbd0aa 100644
--- a/src/Cli/StellaOps.Cli/StellaOps.Cli.csproj
+++ b/src/Cli/StellaOps.Cli/StellaOps.Cli.csproj
@@ -39,9 +39,11 @@
+
+
-
\ No newline at end of file
+
diff --git a/src/Cli/__Libraries/StellaOps.Cli.Plugins.NonCore/StellaOps.Cli.Plugins.NonCore.csproj b/src/Cli/__Libraries/StellaOps.Cli.Plugins.NonCore/StellaOps.Cli.Plugins.NonCore.csproj
index 1dc96cda..31a63525 100644
--- a/src/Cli/__Libraries/StellaOps.Cli.Plugins.NonCore/StellaOps.Cli.Plugins.NonCore.csproj
+++ b/src/Cli/__Libraries/StellaOps.Cli.Plugins.NonCore/StellaOps.Cli.Plugins.NonCore.csproj
@@ -9,7 +9,7 @@
-
+
diff --git a/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/CommandHandlersTests.cs b/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/CommandHandlersTests.cs
index 61a64214..debc4473 100644
--- a/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/CommandHandlersTests.cs
+++ b/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/CommandHandlersTests.cs
@@ -19,11 +19,12 @@ using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Cli.Commands;
using StellaOps.Cli.Configuration;
-using StellaOps.Cli.Services;
-using StellaOps.Cli.Services.Models;
-using StellaOps.Cli.Telemetry;
-using StellaOps.Cli.Tests.Testing;
-using StellaOps.Cryptography;
+using StellaOps.Cli.Services;
+using StellaOps.Cli.Services.Models;
+using StellaOps.Cli.Telemetry;
+using StellaOps.Cli.Tests.Testing;
+using StellaOps.Cryptography;
+using StellaOps.Cryptography.Kms;
using Spectre.Console;
using Spectre.Console.Testing;
@@ -2073,46 +2074,153 @@ public sealed class CommandHandlersTests
}
}
- [Fact]
- public async Task HandleAocVerifyAsync_MissingTenant_ReturnsUsageError()
- {
- var originalExitCode = Environment.ExitCode;
- var originalTenant = Environment.GetEnvironmentVariable("STELLA_TENANT");
-
- try
- {
- Environment.SetEnvironmentVariable("STELLA_TENANT", null);
-
- var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
- var provider = BuildServiceProvider(backend);
-
- await CommandHandlers.HandleAocVerifyAsync(
- provider,
- sinceOption: "24h",
- limitOption: null,
- sourcesOption: null,
- codesOption: null,
- format: "table",
- exportPath: null,
- tenantOverride: null,
- disableColor: true,
- verbose: false,
- cancellationToken: CancellationToken.None);
-
- Assert.Equal(71, Environment.ExitCode);
- }
- finally
- {
- Environment.ExitCode = originalExitCode;
- Environment.SetEnvironmentVariable("STELLA_TENANT", originalTenant);
- }
- }
-
- private static IServiceProvider BuildServiceProvider(
- IBackendOperationsClient backend,
- IScannerExecutor? executor = null,
- IScannerInstaller? installer = null,
- StellaOpsCliOptions? options = null,
+ [Fact]
+ public async Task HandleAocVerifyAsync_MissingTenant_ReturnsUsageError()
+ {
+ var originalExitCode = Environment.ExitCode;
+ var originalTenant = Environment.GetEnvironmentVariable("STELLA_TENANT");
+
+ try
+ {
+ Environment.SetEnvironmentVariable("STELLA_TENANT", null);
+
+ var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
+ var provider = BuildServiceProvider(backend);
+
+ await CommandHandlers.HandleAocVerifyAsync(
+ provider,
+ sinceOption: "24h",
+ limitOption: null,
+ sourcesOption: null,
+ codesOption: null,
+ format: "table",
+ exportPath: null,
+ tenantOverride: null,
+ disableColor: true,
+ verbose: false,
+ cancellationToken: CancellationToken.None);
+
+ Assert.Equal(71, Environment.ExitCode);
+ }
+ finally
+ {
+ Environment.ExitCode = originalExitCode;
+ Environment.SetEnvironmentVariable("STELLA_TENANT", originalTenant);
+ }
+ }
+
+ [Fact]
+ public async Task HandleKmsExportAsync_WritesKeyBundle()
+ {
+ using var kmsRoot = new TempDirectory();
+ using var exportRoot = new TempDirectory();
+ const string passphrase = "P@ssw0rd!";
+
+ using (var client = new FileKmsClient(new FileKmsOptions
+ {
+ RootPath = kmsRoot.Path,
+ Password = passphrase
+ }))
+ {
+ await client.RotateAsync("cli-export-key");
+ }
+
+ var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
+ var provider = BuildServiceProvider(backend);
+ var outputPath = Path.Combine(exportRoot.Path, "export.json");
+ var originalExit = Environment.ExitCode;
+
+ try
+ {
+ await CommandHandlers.HandleKmsExportAsync(
+ provider,
+ kmsRoot.Path,
+ keyId: "cli-export-key",
+ versionId: null,
+ outputPath: outputPath,
+ overwrite: false,
+ passphrase: passphrase,
+ verbose: false,
+ cancellationToken: CancellationToken.None);
+
+ Assert.Equal(0, Environment.ExitCode);
+ Assert.True(File.Exists(outputPath));
+
+ var json = await File.ReadAllTextAsync(outputPath);
+ var material = JsonSerializer.Deserialize(json, new JsonSerializerOptions(JsonSerializerDefaults.Web));
+ Assert.NotNull(material);
+ Assert.Equal("cli-export-key", material!.KeyId);
+ Assert.False(string.IsNullOrWhiteSpace(material.VersionId));
+ Assert.NotNull(material.D);
+ }
+ finally
+ {
+ Environment.ExitCode = originalExit;
+ }
+ }
+
+ [Fact]
+ public async Task HandleKmsImportAsync_ImportsKeyBundle()
+ {
+ using var sourceRoot = new TempDirectory();
+ using var targetRoot = new TempDirectory();
+ const string passphrase = "AnotherP@ssw0rd!";
+
+ KmsKeyMaterial exported;
+ using (var sourceClient = new FileKmsClient(new FileKmsOptions
+ {
+ RootPath = sourceRoot.Path,
+ Password = passphrase
+ }))
+ {
+ await sourceClient.RotateAsync("cli-import-key");
+ exported = await sourceClient.ExportAsync("cli-import-key", null);
+ }
+
+ var exportPath = Path.Combine(sourceRoot.Path, "import.json");
+ var exportJson = JsonSerializer.Serialize(exported, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true });
+ await File.WriteAllTextAsync(exportPath, exportJson);
+
+ var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
+ var provider = BuildServiceProvider(backend);
+ var originalExit = Environment.ExitCode;
+
+ try
+ {
+ await CommandHandlers.HandleKmsImportAsync(
+ provider,
+ targetRoot.Path,
+ keyId: "cli-import-key",
+ inputPath: exportPath,
+ versionOverride: null,
+ passphrase: passphrase,
+ verbose: false,
+ cancellationToken: CancellationToken.None);
+
+ Assert.Equal(0, Environment.ExitCode);
+
+ using var importedClient = new FileKmsClient(new FileKmsOptions
+ {
+ RootPath = targetRoot.Path,
+ Password = passphrase
+ });
+
+ var metadata = await importedClient.GetMetadataAsync("cli-import-key");
+ Assert.Equal(KmsKeyState.Active, metadata.State);
+ Assert.Single(metadata.Versions);
+ Assert.Equal(exported.VersionId, metadata.Versions[0].VersionId);
+ }
+ finally
+ {
+ Environment.ExitCode = originalExit;
+ }
+ }
+
+ private static IServiceProvider BuildServiceProvider(
+ IBackendOperationsClient backend,
+ IScannerExecutor? executor = null,
+ IScannerInstaller? installer = null,
+ StellaOpsCliOptions? options = null,
IStellaOpsTokenClient? tokenClient = null,
IConcelierObservationsClient? concelierClient = null)
{
diff --git a/src/Cli/__Tests/StellaOps.Cli.Tests/Plugins/CliCommandModuleLoaderTests.cs b/src/Cli/__Tests/StellaOps.Cli.Tests/Plugins/CliCommandModuleLoaderTests.cs
index 9fa44cb2..1295737d 100644
--- a/src/Cli/__Tests/StellaOps.Cli.Tests/Plugins/CliCommandModuleLoaderTests.cs
+++ b/src/Cli/__Tests/StellaOps.Cli.Tests/Plugins/CliCommandModuleLoaderTests.cs
@@ -1,32 +1,59 @@
using System;
using System.CommandLine;
using System.IO;
-using System.Threading;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Logging.Abstractions;
-using StellaOps.Cli.Configuration;
-using StellaOps.Cli.Plugins;
-using Xunit;
+using System.Text.Json;
+using System.Threading;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using StellaOps.Cli.Configuration;
+using StellaOps.Cli.Plugins;
+using StellaOps.Cli.Plugins.NonCore;
+using StellaOps.Cli.Tests.Testing;
+using Xunit;
namespace StellaOps.Cli.Tests.Plugins;
public sealed class CliCommandModuleLoaderTests
{
- [Fact]
- public void RegisterModules_LoadsNonCoreCommandsFromPlugin()
- {
- var options = new StellaOpsCliOptions();
- var repoRoot = Path.GetFullPath(
- Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
-
- options.Plugins.BaseDirectory = repoRoot;
- options.Plugins.Directory = "plugins/cli";
- options.Plugins.ManifestSearchPattern = "manifest.json";
-
- var services = new ServiceCollection()
- .AddSingleton(options)
- .BuildServiceProvider();
+ [Fact]
+ public void RegisterModules_LoadsNonCoreCommandsFromPlugin()
+ {
+ using var temp = new TempDirectory();
+ var manifestDirectory = Path.Combine(temp.Path, "plugins", "cli");
+ Directory.CreateDirectory(manifestDirectory);
+
+ var assemblyPath = typeof(NonCoreCliCommandModule).Assembly.Location;
+ var manifestPath = Path.Combine(manifestDirectory, "noncore.manifest.json");
+ var manifestBody = new
+ {
+ schemaVersion = CliPluginManifest.CurrentSchemaVersion,
+ id = "stellaops.cli.plugins.noncore",
+ displayName = "NonCore CLI Commands",
+ version = "1.0.0",
+ requiresRestart = true,
+ entryPoint = new
+ {
+ type = "dotnet",
+ assembly = assemblyPath,
+ typeName = typeof(NonCoreCliCommandModule).FullName
+ }
+ };
+
+ File.WriteAllText(
+ manifestPath,
+ JsonSerializer.Serialize(manifestBody, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true }));
+
+ var options = new StellaOpsCliOptions();
+ options.Plugins.BaseDirectory = temp.Path;
+ options.Plugins.Directory = "plugins/cli";
+ options.Plugins.ManifestSearchPattern = "*.manifest.json";
+ options.Plugins.SearchPatterns.Clear();
+ options.Plugins.SearchPatterns.Add(Path.GetFileName(assemblyPath));
+
+ var services = new ServiceCollection()
+ .AddSingleton(options)
+ .BuildServiceProvider();
var logger = NullLoggerFactory.Instance.CreateLogger();
var loader = new CliCommandModuleLoader(services, options, logger);
diff --git a/src/Cli/__Tests/StellaOps.Cli.Tests/StellaOps.Cli.Tests.csproj b/src/Cli/__Tests/StellaOps.Cli.Tests/StellaOps.Cli.Tests.csproj
index e4a17dfc..46117ab7 100644
--- a/src/Cli/__Tests/StellaOps.Cli.Tests/StellaOps.Cli.Tests.csproj
+++ b/src/Cli/__Tests/StellaOps.Cli.Tests/StellaOps.Cli.Tests.csproj
@@ -24,7 +24,8 @@
+
-
\ No newline at end of file
+
diff --git a/src/__Libraries/StellaOps.Cryptography.Kms/FileKmsClient.cs b/src/__Libraries/StellaOps.Cryptography.Kms/FileKmsClient.cs
index 194cd791..d8b7eddf 100644
--- a/src/__Libraries/StellaOps.Cryptography.Kms/FileKmsClient.cs
+++ b/src/__Libraries/StellaOps.Cryptography.Kms/FileKmsClient.cs
@@ -172,6 +172,99 @@ public sealed class FileKmsClient : IKmsClient, IDisposable
}
}
+ public async Task ImportAsync(
+ string keyId,
+ KmsKeyMaterial material,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
+ ArgumentNullException.ThrowIfNull(material);
+
+ if (material.D is null || material.D.Length == 0)
+ {
+ throw new ArgumentException("Key material must include private key bytes.", nameof(material));
+ }
+
+ if (material.Qx is null || material.Qx.Length == 0 || material.Qy is null || material.Qy.Length == 0)
+ {
+ throw new ArgumentException("Key material must include public key coordinates.", nameof(material));
+ }
+
+ await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ var record = await LoadOrCreateMetadataAsync(keyId, cancellationToken, createIfMissing: true).ConfigureAwait(false)
+ ?? throw new InvalidOperationException("Failed to create or load key metadata.");
+
+ if (!string.Equals(record.Algorithm, material.Algorithm, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidOperationException($"Algorithm mismatch. Expected '{record.Algorithm}', received '{material.Algorithm}'.");
+ }
+
+ var versionId = string.IsNullOrWhiteSpace(material.VersionId)
+ ? $"{DateTimeOffset.UtcNow:yyyyMMddTHHmmssfffZ}"
+ : material.VersionId;
+
+ if (record.Versions.Any(v => string.Equals(v.VersionId, versionId, StringComparison.Ordinal)))
+ {
+ throw new InvalidOperationException($"Key version '{versionId}' already exists for key '{record.KeyId}'.");
+ }
+
+ var curveName = string.IsNullOrWhiteSpace(material.Curve) ? "nistP256" : material.Curve;
+ ResolveCurve(curveName); // validate supported curve
+
+ var privateKeyRecord = new EcdsaPrivateKeyRecord
+ {
+ Curve = curveName,
+ D = Convert.ToBase64String(material.D),
+ Qx = Convert.ToBase64String(material.Qx),
+ Qy = Convert.ToBase64String(material.Qy),
+ };
+
+ var privateBlob = JsonSerializer.SerializeToUtf8Bytes(privateKeyRecord, JsonOptions);
+ try
+ {
+ var envelope = EncryptPrivateKey(privateBlob);
+ var fileName = $"{versionId}.key.json";
+ var keyPath = Path.Combine(GetKeyDirectory(keyId), fileName);
+ await WriteJsonAsync(keyPath, envelope, cancellationToken).ConfigureAwait(false);
+
+ foreach (var existing in record.Versions.Where(v => v.State == KmsKeyState.Active))
+ {
+ existing.State = KmsKeyState.PendingRotation;
+ }
+
+ var createdAt = material.CreatedAt == default ? DateTimeOffset.UtcNow : material.CreatedAt;
+ var publicKey = CombinePublicCoordinates(material.Qx, material.Qy);
+
+ record.Versions.Add(new KeyVersionRecord
+ {
+ VersionId = versionId,
+ State = KmsKeyState.Active,
+ CreatedAt = createdAt,
+ PublicKey = Convert.ToBase64String(publicKey),
+ CurveName = curveName,
+ FileName = fileName,
+ });
+
+ record.CreatedAt ??= createdAt;
+ record.State = KmsKeyState.Active;
+ record.ActiveVersion = versionId;
+
+ await SaveMetadataAsync(record, cancellationToken).ConfigureAwait(false);
+ return ToMetadata(record);
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(privateBlob);
+ }
+ }
+ finally
+ {
+ _mutex.Release();
+ }
+ }
+
public async Task RotateAsync(string keyId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
@@ -600,4 +693,17 @@ public sealed class FileKmsClient : IKmsClient, IDisposable
};
public void Dispose() => _mutex.Dispose();
+
+ private static byte[] CombinePublicCoordinates(ReadOnlySpan qx, ReadOnlySpan qy)
+ {
+ if (qx.IsEmpty || qy.IsEmpty)
+ {
+ return Array.Empty();
+ }
+
+ var publicKey = new byte[qx.Length + qy.Length];
+ qx.CopyTo(publicKey);
+ qy.CopyTo(publicKey.AsSpan(qx.Length));
+ return publicKey;
+ }
}
diff --git a/src/__Libraries/StellaOps.Cryptography.Kms/TASKS.md b/src/__Libraries/StellaOps.Cryptography.Kms/TASKS.md
index be7c14cc..bafa1ce0 100644
--- a/src/__Libraries/StellaOps.Cryptography.Kms/TASKS.md
+++ b/src/__Libraries/StellaOps.Cryptography.Kms/TASKS.md
@@ -3,8 +3,8 @@
## Sprint 72 – Abstractions & File Driver
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
-| KMS-72-001 | DOING (2025-10-29) | KMS Guild | — | Implement KMS interface (sign, verify, metadata, rotate, revoke) and file-based key driver with encrypted at-rest storage. | Interface + file driver operational; unit tests cover sign/verify/rotation; lint passes.
2025-10-29: `FileKmsClient` (ES256) file driver scaffolding committed under `StellaOps.Cryptography.Kms`; includes disk encryption + unit tests. Follow-up: address PBKDF2/AesGcm warnings and wire into Authority services.
2025-10-29 18:40Z: Hardened PBKDF2 iteration floor (≥600k), switched to tag-size explicit `AesGcm` usage, removed transient array allocations, and refreshed unit tests (`StellaOps.Cryptography.Kms.Tests`). |
-| KMS-72-002 | TODO | KMS Guild | KMS-72-001 | Add CLI support for importing/exporting file-based keys with password protection. | CLI commands functional; docs updated; integration tests pass. |
+| KMS-72-001 | DONE (2025-10-30) | KMS Guild | — | Implement KMS interface (sign, verify, metadata, rotate, revoke) and file-based key driver with encrypted at-rest storage. | Interface + file driver operational; unit tests cover sign/verify/rotation; lint passes.
2025-10-29: `FileKmsClient` (ES256) file driver scaffolding committed under `StellaOps.Cryptography.Kms`; includes disk encryption + unit tests. Follow-up: address PBKDF2/AesGcm warnings and wire into Authority services.
2025-10-29 18:40Z: Hardened PBKDF2 iteration floor (≥600k), switched to tag-size explicit `AesGcm` usage, removed transient array allocations, and refreshed unit tests (`StellaOps.Cryptography.Kms.Tests`).
2025-10-30: Cleared remaining PBKDF2/AesGcm analyser warnings, validated Authority host wiring for `AddFileKms`, reran `dotnet test src/__Libraries/__Tests/StellaOps.Cryptography.Kms.Tests/StellaOps.Cryptography.Kms.Tests.csproj --no-build`, and confirmed clean `dotnet build` (no warnings). |
+| KMS-72-002 | DONE (2025-10-30) | KMS Guild | KMS-72-001 | Add CLI support for importing/exporting file-based keys with password protection. | CLI commands functional; docs updated; integration tests pass.
2025-10-30: CLI requirements reviewed; new `stella kms` verb planned for file driver import/export flow with Spectre prompts + tests.
2025-10-30 20:15Z: Shipped `stella kms export|import` (passphrase/env/prompt support), wired `FileKmsClient.ImportAsync`, updated plugin manifest loader tests, and ran `dotnet build`/`dotnet test` for KMS + CLI suites. |
## Sprint 73 – Cloud & HSM Integration
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |