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 |