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 @@
|
|||||||
<package pattern="Microsoft.Extensions.*" />
|
<package pattern="Microsoft.Extensions.*" />
|
||||||
<package pattern="Microsoft.AspNetCore.*" />
|
<package pattern="Microsoft.AspNetCore.*" />
|
||||||
<package pattern="Microsoft.Data.Sqlite" />
|
<package pattern="Microsoft.Data.Sqlite" />
|
||||||
|
<package pattern="System.Diagnostics.*" />
|
||||||
</packageSource>
|
</packageSource>
|
||||||
<packageSource key="nuget.org">
|
<packageSource key="nuget.org">
|
||||||
<package pattern="*" />
|
<package pattern="*" />
|
||||||
|
|||||||
@@ -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.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-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/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-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 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. |
|
| 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-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-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/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-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 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. |
|
| 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. |
|
||||||
|
|||||||
@@ -117,6 +117,18 @@ src/
|
|||||||
* Replays guard checks against stored raw documents. Maps backend `ERR_AOC_00x` codes onto deterministic exit codes so CI can block regressions.
|
* 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.
|
* 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 <logicalId> --output <file> [--version <id>] [--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 <logicalId> --input <file> [--version <override>]`
|
||||||
|
|
||||||
|
* 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3) AuthN: Authority + DPoP
|
## 3) AuthN: Authority + DPoP
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ internal static class CommandFactory
|
|||||||
root.Add(BuildPolicyCommand(services, options, verboseOption, cancellationToken));
|
root.Add(BuildPolicyCommand(services, options, verboseOption, cancellationToken));
|
||||||
root.Add(BuildFindingsCommand(services, verboseOption, cancellationToken));
|
root.Add(BuildFindingsCommand(services, verboseOption, cancellationToken));
|
||||||
root.Add(BuildConfigCommand(options));
|
root.Add(BuildConfigCommand(options));
|
||||||
|
root.Add(BuildKmsCommand(services, verboseOption, cancellationToken));
|
||||||
root.Add(BuildVulnCommand(services, verboseOption, cancellationToken));
|
root.Add(BuildVulnCommand(services, verboseOption, cancellationToken));
|
||||||
|
|
||||||
var pluginLogger = loggerFactory.CreateLogger<CliCommandModuleLoader>();
|
var pluginLogger = loggerFactory.CreateLogger<CliCommandModuleLoader>();
|
||||||
@@ -152,6 +153,122 @@ internal static class CommandFactory
|
|||||||
return scan;
|
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)
|
private static Command BuildDatabaseCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var db = new Command("db", "Trigger Concelier database operations via backend jobs.");
|
var db = new Command("db", "Trigger Concelier database operations via backend jobs.");
|
||||||
|
|||||||
@@ -26,11 +26,18 @@ using StellaOps.Cli.Services;
|
|||||||
using StellaOps.Cli.Services.Models;
|
using StellaOps.Cli.Services.Models;
|
||||||
using StellaOps.Cli.Telemetry;
|
using StellaOps.Cli.Telemetry;
|
||||||
using StellaOps.Cryptography;
|
using StellaOps.Cryptography;
|
||||||
|
using StellaOps.Cryptography.Kms;
|
||||||
|
|
||||||
namespace StellaOps.Cli.Commands;
|
namespace StellaOps.Cli.Commands;
|
||||||
|
|
||||||
internal static class CommandHandlers
|
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(
|
public static async Task HandleScannerDownloadAsync(
|
||||||
IServiceProvider services,
|
IServiceProvider services,
|
||||||
string channel,
|
string channel,
|
||||||
@@ -5536,6 +5543,186 @@ internal static class CommandHandlers
|
|||||||
return Encoding.UTF8;
|
return Encoding.UTF8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
private static bool TryDecodeBase64(string text, out byte[] decoded)
|
||||||
{
|
{
|
||||||
decoded = Array.Empty<byte>();
|
decoded = Array.Empty<byte>();
|
||||||
|
|||||||
29
src/Cli/StellaOps.Cli/Prompts/KmsPassphrasePrompt.cs
Normal file
29
src/Cli/StellaOps.Cli/Prompts/KmsPassphrasePrompt.cs
Normal file
@@ -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<string>(message)
|
||||||
|
.Secret()
|
||||||
|
.PromptStyle("cyan1")
|
||||||
|
.Validate(value => string.IsNullOrWhiteSpace(value)
|
||||||
|
? ValidationResult.Error("Passphrase cannot be empty.")
|
||||||
|
: ValidationResult.Success()));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,8 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||||
|
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||||
|
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Kms/StellaOps.Cryptography.Kms.csproj" />
|
||||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
||||||
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\StellaOps.Cli\StellaOps.Cli.csproj" />
|
<ProjectReference Include="..\..\StellaOps.Cli\StellaOps.Cli.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<Target Name="CopyPluginBinaries" AfterTargets="Build">
|
<Target Name="CopyPluginBinaries" AfterTargets="Build">
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ using StellaOps.Cli.Services.Models;
|
|||||||
using StellaOps.Cli.Telemetry;
|
using StellaOps.Cli.Telemetry;
|
||||||
using StellaOps.Cli.Tests.Testing;
|
using StellaOps.Cli.Tests.Testing;
|
||||||
using StellaOps.Cryptography;
|
using StellaOps.Cryptography;
|
||||||
|
using StellaOps.Cryptography.Kms;
|
||||||
using Spectre.Console;
|
using Spectre.Console;
|
||||||
using Spectre.Console.Testing;
|
using Spectre.Console.Testing;
|
||||||
|
|
||||||
@@ -2108,6 +2109,113 @@ public sealed class CommandHandlersTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[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<KmsKeyMaterial>(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(
|
private static IServiceProvider BuildServiceProvider(
|
||||||
IBackendOperationsClient backend,
|
IBackendOperationsClient backend,
|
||||||
IScannerExecutor? executor = null,
|
IScannerExecutor? executor = null,
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.CommandLine;
|
using System.CommandLine;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using StellaOps.Cli.Configuration;
|
using StellaOps.Cli.Configuration;
|
||||||
using StellaOps.Cli.Plugins;
|
using StellaOps.Cli.Plugins;
|
||||||
|
using StellaOps.Cli.Plugins.NonCore;
|
||||||
|
using StellaOps.Cli.Tests.Testing;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace StellaOps.Cli.Tests.Plugins;
|
namespace StellaOps.Cli.Tests.Plugins;
|
||||||
@@ -16,13 +19,37 @@ public sealed class CliCommandModuleLoaderTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void RegisterModules_LoadsNonCoreCommandsFromPlugin()
|
public void RegisterModules_LoadsNonCoreCommandsFromPlugin()
|
||||||
{
|
{
|
||||||
var options = new StellaOpsCliOptions();
|
using var temp = new TempDirectory();
|
||||||
var repoRoot = Path.GetFullPath(
|
var manifestDirectory = Path.Combine(temp.Path, "plugins", "cli");
|
||||||
Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
|
Directory.CreateDirectory(manifestDirectory);
|
||||||
|
|
||||||
options.Plugins.BaseDirectory = repoRoot;
|
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.Directory = "plugins/cli";
|
||||||
options.Plugins.ManifestSearchPattern = "manifest.json";
|
options.Plugins.ManifestSearchPattern = "*.manifest.json";
|
||||||
|
options.Plugins.SearchPatterns.Clear();
|
||||||
|
options.Plugins.SearchPatterns.Add(Path.GetFileName(assemblyPath));
|
||||||
|
|
||||||
var services = new ServiceCollection()
|
var services = new ServiceCollection()
|
||||||
.AddSingleton(options)
|
.AddSingleton(options)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Spectre.Console.Testing" Version="0.48.0" />
|
<PackageReference Include="Spectre.Console.Testing" Version="0.48.0" />
|
||||||
<ProjectReference Include="../../StellaOps.Cli/StellaOps.Cli.csproj" />
|
<ProjectReference Include="../../StellaOps.Cli/StellaOps.Cli.csproj" />
|
||||||
|
<ProjectReference Include="../../__Libraries/StellaOps.Cli.Plugins.NonCore/StellaOps.Cli.Plugins.NonCore.csproj" />
|
||||||
<ProjectReference Include="../../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
<ProjectReference Include="../../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,99 @@ public sealed class FileKmsClient : IKmsClient, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<KmsKeyMetadata> 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<KmsKeyMetadata> RotateAsync(string keyId, CancellationToken cancellationToken = default)
|
public async Task<KmsKeyMetadata> RotateAsync(string keyId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||||
@@ -600,4 +693,17 @@ public sealed class FileKmsClient : IKmsClient, IDisposable
|
|||||||
};
|
};
|
||||||
|
|
||||||
public void Dispose() => _mutex.Dispose();
|
public void Dispose() => _mutex.Dispose();
|
||||||
|
|
||||||
|
private static byte[] CombinePublicCoordinates(ReadOnlySpan<byte> qx, ReadOnlySpan<byte> qy)
|
||||||
|
{
|
||||||
|
if (qx.IsEmpty || qy.IsEmpty)
|
||||||
|
{
|
||||||
|
return Array.Empty<byte>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var publicKey = new byte[qx.Length + qy.Length];
|
||||||
|
qx.CopyTo(publicKey);
|
||||||
|
qy.CopyTo(publicKey.AsSpan(qx.Length));
|
||||||
|
return publicKey;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
## Sprint 72 – Abstractions & File Driver
|
## Sprint 72 – Abstractions & File Driver
|
||||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
| 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.<br>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.<br>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-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.<br>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.<br>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`).<br>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 | 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-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.<br>2025-10-30: CLI requirements reviewed; new `stella kms` verb planned for file driver import/export flow with Spectre prompts + tests.<br>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
|
## Sprint 73 – Cloud & HSM Integration
|
||||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||||
|
|||||||
Reference in New Issue
Block a user