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:
@@ -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)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
@@ -600,4 +693,17 @@ public sealed class FileKmsClient : IKmsClient, IDisposable
|
||||
};
|
||||
|
||||
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
|
||||
| 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-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.<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 | 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
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|
||||
Reference in New Issue
Block a user