up
This commit is contained in:
		
							
								
								
									
										593
									
								
								src/__Libraries/StellaOps.Cryptography.Kms/FileKmsClient.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										593
									
								
								src/__Libraries/StellaOps.Cryptography.Kms/FileKmsClient.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,593 @@
 | 
			
		||||
using System.Collections.Immutable;
 | 
			
		||||
using System.Security.Cryptography;
 | 
			
		||||
using System.Text;
 | 
			
		||||
using System.Text.Json;
 | 
			
		||||
using System.Text.Json.Serialization;
 | 
			
		||||
namespace StellaOps.Cryptography.Kms;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// File-backed KMS implementation that stores encrypted key material on disk.
 | 
			
		||||
/// </summary>
 | 
			
		||||
public sealed class FileKmsClient : IKmsClient, IDisposable
 | 
			
		||||
{
 | 
			
		||||
    private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
 | 
			
		||||
    {
 | 
			
		||||
        WriteIndented = true,
 | 
			
		||||
        Converters =
 | 
			
		||||
        {
 | 
			
		||||
            new JsonStringEnumConverter(),
 | 
			
		||||
        },
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    private readonly FileKmsOptions _options;
 | 
			
		||||
    private readonly SemaphoreSlim _mutex = new(1, 1);
 | 
			
		||||
 | 
			
		||||
    public FileKmsClient(FileKmsOptions options)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(options);
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(options.RootPath))
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentException("Root path must be provided.", nameof(options));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(options.Password))
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentException("Password must be provided.", nameof(options));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        _options = options;
 | 
			
		||||
        Directory.CreateDirectory(_options.RootPath);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<KmsSignResult> SignAsync(
 | 
			
		||||
        string keyId,
 | 
			
		||||
        string? keyVersion,
 | 
			
		||||
        ReadOnlyMemory<byte> data,
 | 
			
		||||
        CancellationToken cancellationToken = default)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
 | 
			
		||||
        if (data.IsEmpty)
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentException("Data cannot be empty.", nameof(data));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var record = await LoadOrCreateMetadataAsync(keyId, cancellationToken, createIfMissing: false).ConfigureAwait(false)
 | 
			
		||||
                ?? throw new InvalidOperationException($"Key '{keyId}' does not exist.");
 | 
			
		||||
 | 
			
		||||
            if (record.State == KmsKeyState.Revoked)
 | 
			
		||||
            {
 | 
			
		||||
                throw new InvalidOperationException($"Key '{keyId}' is revoked and cannot be used for signing.");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var version = ResolveVersion(record, keyVersion);
 | 
			
		||||
            if (version.State != KmsKeyState.Active)
 | 
			
		||||
            {
 | 
			
		||||
                throw new InvalidOperationException($"Key version '{version.VersionId}' is not active. Current state: {version.State}");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var privateKey = await LoadPrivateKeyAsync(record, version, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            var signature = SignData(privateKey, data.Span);
 | 
			
		||||
            return new KmsSignResult(record.KeyId, version.VersionId, record.Algorithm, signature);
 | 
			
		||||
        }
 | 
			
		||||
        finally
 | 
			
		||||
        {
 | 
			
		||||
            _mutex.Release();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<bool> VerifyAsync(
 | 
			
		||||
        string keyId,
 | 
			
		||||
        string? keyVersion,
 | 
			
		||||
        ReadOnlyMemory<byte> data,
 | 
			
		||||
        ReadOnlyMemory<byte> signature,
 | 
			
		||||
        CancellationToken cancellationToken = default)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
 | 
			
		||||
        if (data.IsEmpty || signature.IsEmpty)
 | 
			
		||||
        {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var record = await LoadOrCreateMetadataAsync(keyId, cancellationToken, createIfMissing: false).ConfigureAwait(false);
 | 
			
		||||
            if (record is null)
 | 
			
		||||
            {
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var version = ResolveVersion(record, keyVersion);
 | 
			
		||||
            if (string.IsNullOrWhiteSpace(version.PublicKey))
 | 
			
		||||
            {
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return VerifyData(version.CurveName, version.PublicKey, data.Span, signature.Span);
 | 
			
		||||
        }
 | 
			
		||||
        finally
 | 
			
		||||
        {
 | 
			
		||||
            _mutex.Release();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<KmsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken = default)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
 | 
			
		||||
 | 
			
		||||
        await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var record = await LoadOrCreateMetadataAsync(keyId, cancellationToken, createIfMissing: false).ConfigureAwait(false)
 | 
			
		||||
                ?? throw new InvalidOperationException($"Key '{keyId}' does not exist.");
 | 
			
		||||
            return ToMetadata(record);
 | 
			
		||||
        }
 | 
			
		||||
        finally
 | 
			
		||||
        {
 | 
			
		||||
            _mutex.Release();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<KmsKeyMaterial> ExportAsync(string keyId, string? keyVersion, CancellationToken cancellationToken = default)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
 | 
			
		||||
 | 
			
		||||
        await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var record = await LoadOrCreateMetadataAsync(keyId, cancellationToken, createIfMissing: false).ConfigureAwait(false)
 | 
			
		||||
                ?? throw new InvalidOperationException($"Key '{keyId}' does not exist.");
 | 
			
		||||
 | 
			
		||||
            var version = ResolveVersion(record, keyVersion);
 | 
			
		||||
            if (string.IsNullOrWhiteSpace(version.PublicKey))
 | 
			
		||||
            {
 | 
			
		||||
                throw new InvalidOperationException($"Key '{keyId}' version '{version.VersionId}' does not have public key material.");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var privateKey = await LoadPrivateKeyAsync(record, version, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            return new KmsKeyMaterial(
 | 
			
		||||
                record.KeyId,
 | 
			
		||||
                version.VersionId,
 | 
			
		||||
                record.Algorithm,
 | 
			
		||||
                version.CurveName,
 | 
			
		||||
                Convert.FromBase64String(privateKey.D),
 | 
			
		||||
                Convert.FromBase64String(privateKey.Qx),
 | 
			
		||||
                Convert.FromBase64String(privateKey.Qy),
 | 
			
		||||
                version.CreatedAt);
 | 
			
		||||
        }
 | 
			
		||||
        finally
 | 
			
		||||
        {
 | 
			
		||||
            _mutex.Release();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<KmsKeyMetadata> RotateAsync(string keyId, CancellationToken cancellationToken = default)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
 | 
			
		||||
 | 
			
		||||
        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 (record.State == KmsKeyState.Revoked)
 | 
			
		||||
            {
 | 
			
		||||
                throw new InvalidOperationException($"Key '{keyId}' has been revoked and cannot be rotated.");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var timestamp = DateTimeOffset.UtcNow;
 | 
			
		||||
            var versionId = $"{timestamp:yyyyMMddTHHmmssfffZ}";
 | 
			
		||||
            var keyData = CreateKeyMaterial(record.Algorithm);
 | 
			
		||||
 | 
			
		||||
            try
 | 
			
		||||
            {
 | 
			
		||||
                var envelope = EncryptPrivateKey(keyData.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;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                record.Versions.Add(new KeyVersionRecord
 | 
			
		||||
                {
 | 
			
		||||
                    VersionId = versionId,
 | 
			
		||||
                    State = KmsKeyState.Active,
 | 
			
		||||
                    CreatedAt = timestamp,
 | 
			
		||||
                    PublicKey = keyData.PublicKey,
 | 
			
		||||
                    CurveName = keyData.Curve,
 | 
			
		||||
                    FileName = fileName,
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
                record.CreatedAt ??= timestamp;
 | 
			
		||||
                record.State = KmsKeyState.Active;
 | 
			
		||||
                record.ActiveVersion = versionId;
 | 
			
		||||
 | 
			
		||||
                await SaveMetadataAsync(record, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                return ToMetadata(record);
 | 
			
		||||
            }
 | 
			
		||||
            finally
 | 
			
		||||
            {
 | 
			
		||||
                CryptographicOperations.ZeroMemory(keyData.PrivateBlob);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        finally
 | 
			
		||||
        {
 | 
			
		||||
            _mutex.Release();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task RevokeAsync(string keyId, CancellationToken cancellationToken = default)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
 | 
			
		||||
 | 
			
		||||
        await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var record = await LoadOrCreateMetadataAsync(keyId, cancellationToken, createIfMissing: false).ConfigureAwait(false)
 | 
			
		||||
                ?? throw new InvalidOperationException($"Key '{keyId}' does not exist.");
 | 
			
		||||
 | 
			
		||||
            var timestamp = DateTimeOffset.UtcNow;
 | 
			
		||||
            record.State = KmsKeyState.Revoked;
 | 
			
		||||
            foreach (var version in record.Versions)
 | 
			
		||||
            {
 | 
			
		||||
                if (version.State != KmsKeyState.Revoked)
 | 
			
		||||
                {
 | 
			
		||||
                    version.State = KmsKeyState.Revoked;
 | 
			
		||||
                    version.DeactivatedAt = timestamp;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            await SaveMetadataAsync(record, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        finally
 | 
			
		||||
        {
 | 
			
		||||
            _mutex.Release();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string GetMetadataPath(string root, string keyId)
 | 
			
		||||
        => Path.Combine(root, keyId, "metadata.json");
 | 
			
		||||
 | 
			
		||||
    private string GetKeyDirectory(string keyId)
 | 
			
		||||
    {
 | 
			
		||||
        var path = Path.Combine(_options.RootPath, keyId);
 | 
			
		||||
        Directory.CreateDirectory(path);
 | 
			
		||||
        return path;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task<KeyMetadataRecord?> LoadOrCreateMetadataAsync(
 | 
			
		||||
        string keyId,
 | 
			
		||||
        CancellationToken cancellationToken,
 | 
			
		||||
        bool createIfMissing)
 | 
			
		||||
    {
 | 
			
		||||
        var metadataPath = GetMetadataPath(_options.RootPath, keyId);
 | 
			
		||||
        if (!File.Exists(metadataPath))
 | 
			
		||||
        {
 | 
			
		||||
            if (!createIfMissing)
 | 
			
		||||
            {
 | 
			
		||||
                return null;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var record = new KeyMetadataRecord
 | 
			
		||||
            {
 | 
			
		||||
                KeyId = keyId,
 | 
			
		||||
                Algorithm = _options.Algorithm,
 | 
			
		||||
                State = KmsKeyState.Active,
 | 
			
		||||
                CreatedAt = DateTimeOffset.UtcNow,
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            await SaveMetadataAsync(record, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            return record;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await using var stream = File.Open(metadataPath, FileMode.Open, FileAccess.Read, FileShare.Read);
 | 
			
		||||
        var loadedRecord = await JsonSerializer.DeserializeAsync<KeyMetadataRecord>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        if (loadedRecord is null)
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(loadedRecord.Algorithm))
 | 
			
		||||
        {
 | 
			
		||||
            loadedRecord.Algorithm = KmsAlgorithms.Es256;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        foreach (var version in loadedRecord.Versions)
 | 
			
		||||
        {
 | 
			
		||||
            if (string.IsNullOrWhiteSpace(version.CurveName))
 | 
			
		||||
            {
 | 
			
		||||
                version.CurveName = "nistP256";
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return loadedRecord;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task SaveMetadataAsync(KeyMetadataRecord record, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var metadataPath = GetMetadataPath(_options.RootPath, record.KeyId);
 | 
			
		||||
        Directory.CreateDirectory(Path.GetDirectoryName(metadataPath)!);
 | 
			
		||||
        await using var stream = File.Open(metadataPath, FileMode.Create, FileAccess.Write, FileShare.None);
 | 
			
		||||
        await JsonSerializer.SerializeAsync(stream, record, JsonOptions, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task<EcdsaPrivateKeyRecord> LoadPrivateKeyAsync(KeyMetadataRecord record, KeyVersionRecord version, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var keyPath = Path.Combine(GetKeyDirectory(record.KeyId), version.FileName);
 | 
			
		||||
        if (!File.Exists(keyPath))
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException($"Key material for version '{version.VersionId}' was not found.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await using var stream = File.Open(keyPath, FileMode.Open, FileAccess.Read, FileShare.Read);
 | 
			
		||||
        var envelope = await JsonSerializer.DeserializeAsync<KeyEnvelope>(stream, JsonOptions, cancellationToken).ConfigureAwait(false)
 | 
			
		||||
            ?? throw new InvalidOperationException("Key envelope could not be deserialized.");
 | 
			
		||||
 | 
			
		||||
        var payload = DecryptPrivateKey(envelope);
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            return JsonSerializer.Deserialize<EcdsaPrivateKeyRecord>(payload, JsonOptions)
 | 
			
		||||
                ?? throw new InvalidOperationException("Key payload could not be deserialized.");
 | 
			
		||||
        }
 | 
			
		||||
        finally
 | 
			
		||||
        {
 | 
			
		||||
            CryptographicOperations.ZeroMemory(payload);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static KeyVersionRecord ResolveVersion(KeyMetadataRecord record, string? keyVersion)
 | 
			
		||||
    {
 | 
			
		||||
        KeyVersionRecord? version = null;
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(keyVersion))
 | 
			
		||||
        {
 | 
			
		||||
            version = record.Versions.SingleOrDefault(v => string.Equals(v.VersionId, keyVersion, StringComparison.Ordinal));
 | 
			
		||||
            if (version is null)
 | 
			
		||||
            {
 | 
			
		||||
                throw new InvalidOperationException($"Key version '{keyVersion}' does not exist for key '{record.KeyId}'.");
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        else if (!string.IsNullOrWhiteSpace(record.ActiveVersion))
 | 
			
		||||
        {
 | 
			
		||||
            version = record.Versions.SingleOrDefault(v => string.Equals(v.VersionId, record.ActiveVersion, StringComparison.Ordinal));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        version ??= record.Versions
 | 
			
		||||
            .Where(v => v.State == KmsKeyState.Active)
 | 
			
		||||
            .OrderByDescending(v => v.CreatedAt)
 | 
			
		||||
            .FirstOrDefault();
 | 
			
		||||
 | 
			
		||||
        if (version is null)
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException($"Key '{record.KeyId}' does not have an active version.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return version;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private EcdsaKeyData CreateKeyMaterial(string algorithm)
 | 
			
		||||
    {
 | 
			
		||||
        if (!string.Equals(algorithm, KmsAlgorithms.Es256, StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
        {
 | 
			
		||||
            throw new NotSupportedException($"Algorithm '{algorithm}' is not supported by the file KMS driver.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
 | 
			
		||||
        var parameters = ecdsa.ExportParameters(true);
 | 
			
		||||
 | 
			
		||||
        var keyRecord = new EcdsaPrivateKeyRecord
 | 
			
		||||
        {
 | 
			
		||||
            Curve = "nistP256",
 | 
			
		||||
            D = Convert.ToBase64String(parameters.D ?? Array.Empty<byte>()),
 | 
			
		||||
            Qx = Convert.ToBase64String(parameters.Q.X ?? Array.Empty<byte>()),
 | 
			
		||||
            Qy = Convert.ToBase64String(parameters.Q.Y ?? Array.Empty<byte>()),
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        var privateBlob = JsonSerializer.SerializeToUtf8Bytes(keyRecord, JsonOptions);
 | 
			
		||||
 | 
			
		||||
        var qx = parameters.Q.X ?? Array.Empty<byte>();
 | 
			
		||||
        var qy = parameters.Q.Y ?? Array.Empty<byte>();
 | 
			
		||||
        var publicKey = new byte[qx.Length + qy.Length];
 | 
			
		||||
        Buffer.BlockCopy(qx, 0, publicKey, 0, qx.Length);
 | 
			
		||||
        Buffer.BlockCopy(qy, 0, publicKey, qx.Length, qy.Length);
 | 
			
		||||
 | 
			
		||||
        return new EcdsaKeyData(privateBlob, Convert.ToBase64String(publicKey), keyRecord.Curve);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private byte[] SignData(EcdsaPrivateKeyRecord privateKey, ReadOnlySpan<byte> data)
 | 
			
		||||
    {
 | 
			
		||||
        var parameters = new ECParameters
 | 
			
		||||
        {
 | 
			
		||||
            Curve = ResolveCurve(privateKey.Curve),
 | 
			
		||||
            D = Convert.FromBase64String(privateKey.D),
 | 
			
		||||
            Q = new ECPoint
 | 
			
		||||
            {
 | 
			
		||||
                X = Convert.FromBase64String(privateKey.Qx),
 | 
			
		||||
                Y = Convert.FromBase64String(privateKey.Qy),
 | 
			
		||||
            },
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        using var ecdsa = ECDsa.Create();
 | 
			
		||||
        ecdsa.ImportParameters(parameters);
 | 
			
		||||
        return ecdsa.SignData(data.ToArray(), HashAlgorithmName.SHA256);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private bool VerifyData(string curveName, string publicKeyBase64, ReadOnlySpan<byte> data, ReadOnlySpan<byte> signature)
 | 
			
		||||
    {
 | 
			
		||||
        var publicKey = Convert.FromBase64String(publicKeyBase64);
 | 
			
		||||
        if (publicKey.Length % 2 != 0)
 | 
			
		||||
        {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var half = publicKey.Length / 2;
 | 
			
		||||
        var qx = publicKey[..half];
 | 
			
		||||
        var qy = publicKey[half..];
 | 
			
		||||
 | 
			
		||||
        var parameters = new ECParameters
 | 
			
		||||
        {
 | 
			
		||||
            Curve = ResolveCurve(curveName),
 | 
			
		||||
            Q = new ECPoint
 | 
			
		||||
            {
 | 
			
		||||
                X = qx,
 | 
			
		||||
                Y = qy,
 | 
			
		||||
            },
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        using var ecdsa = ECDsa.Create();
 | 
			
		||||
        ecdsa.ImportParameters(parameters);
 | 
			
		||||
        return ecdsa.VerifyData(data.ToArray(), signature.ToArray(), HashAlgorithmName.SHA256);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private KeyEnvelope EncryptPrivateKey(ReadOnlySpan<byte> privateKey)
 | 
			
		||||
    {
 | 
			
		||||
        var salt = RandomNumberGenerator.GetBytes(16);
 | 
			
		||||
        var nonce = RandomNumberGenerator.GetBytes(12);
 | 
			
		||||
        var key = DeriveKey(salt);
 | 
			
		||||
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var ciphertext = new byte[privateKey.Length];
 | 
			
		||||
            var tag = new byte[16];
 | 
			
		||||
            var plaintextCopy = privateKey.ToArray();
 | 
			
		||||
 | 
			
		||||
            try
 | 
			
		||||
            {
 | 
			
		||||
                AesGcm.Encrypt(key, nonce, plaintextCopy, ciphertext, tag);
 | 
			
		||||
            }
 | 
			
		||||
            finally
 | 
			
		||||
            {
 | 
			
		||||
                CryptographicOperations.ZeroMemory(plaintextCopy);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return new KeyEnvelope(
 | 
			
		||||
                Ciphertext: Convert.ToBase64String(ciphertext),
 | 
			
		||||
                Nonce: Convert.ToBase64String(nonce),
 | 
			
		||||
                Tag: Convert.ToBase64String(tag),
 | 
			
		||||
                Salt: Convert.ToBase64String(salt));
 | 
			
		||||
        }
 | 
			
		||||
        finally
 | 
			
		||||
        {
 | 
			
		||||
            CryptographicOperations.ZeroMemory(key);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private byte[] DecryptPrivateKey(KeyEnvelope envelope)
 | 
			
		||||
    {
 | 
			
		||||
        var salt = Convert.FromBase64String(envelope.Salt);
 | 
			
		||||
        var nonce = Convert.FromBase64String(envelope.Nonce);
 | 
			
		||||
        var tag = Convert.FromBase64String(envelope.Tag);
 | 
			
		||||
        var ciphertext = Convert.FromBase64String(envelope.Ciphertext);
 | 
			
		||||
 | 
			
		||||
        var key = DeriveKey(salt);
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var plaintext = new byte[ciphertext.Length];
 | 
			
		||||
            AesGcm.Decrypt(key, nonce, ciphertext, tag, plaintext);
 | 
			
		||||
 | 
			
		||||
            return plaintext;
 | 
			
		||||
        }
 | 
			
		||||
        finally
 | 
			
		||||
        {
 | 
			
		||||
            CryptographicOperations.ZeroMemory(key);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private byte[] DeriveKey(byte[] salt)
 | 
			
		||||
    {
 | 
			
		||||
        var key = new byte[32];
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var passwordBytes = Encoding.UTF8.GetBytes(_options.Password);
 | 
			
		||||
            try
 | 
			
		||||
            {
 | 
			
		||||
                var derived = Rfc2898DeriveBytes.Pbkdf2(passwordBytes, salt, _options.KeyDerivationIterations, HashAlgorithmName.SHA256, key.Length);
 | 
			
		||||
                derived.CopyTo(key.AsSpan());
 | 
			
		||||
                CryptographicOperations.ZeroMemory(derived);
 | 
			
		||||
                return key;
 | 
			
		||||
            }
 | 
			
		||||
            finally
 | 
			
		||||
            {
 | 
			
		||||
                CryptographicOperations.ZeroMemory(passwordBytes);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        catch
 | 
			
		||||
        {
 | 
			
		||||
            CryptographicOperations.ZeroMemory(key);
 | 
			
		||||
            throw;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static async Task WriteJsonAsync<T>(string path, T value, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        await using var stream = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.None);
 | 
			
		||||
        await JsonSerializer.SerializeAsync(stream, value, JsonOptions, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static KmsKeyMetadata ToMetadata(KeyMetadataRecord record)
 | 
			
		||||
    {
 | 
			
		||||
        var versions = record.Versions
 | 
			
		||||
            .Select(v => new KmsKeyVersionMetadata(
 | 
			
		||||
                v.VersionId,
 | 
			
		||||
                v.State,
 | 
			
		||||
                v.CreatedAt,
 | 
			
		||||
                v.DeactivatedAt,
 | 
			
		||||
                v.PublicKey,
 | 
			
		||||
                v.CurveName))
 | 
			
		||||
            .ToImmutableArray();
 | 
			
		||||
 | 
			
		||||
        var createdAt = record.CreatedAt ?? (versions.Length > 0 ? versions.Min(v => v.CreatedAt) : DateTimeOffset.UtcNow);
 | 
			
		||||
        return new KmsKeyMetadata(record.KeyId, record.Algorithm, record.State, createdAt, versions);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private sealed class KeyMetadataRecord
 | 
			
		||||
    {
 | 
			
		||||
        public string KeyId { get; set; } = string.Empty;
 | 
			
		||||
        public string Algorithm { get; set; } = KmsAlgorithms.Es256;
 | 
			
		||||
        public KmsKeyState State { get; set; } = KmsKeyState.Active;
 | 
			
		||||
        public DateTimeOffset? CreatedAt { get; set; }
 | 
			
		||||
        public string? ActiveVersion { get; set; }
 | 
			
		||||
        public List<KeyVersionRecord> Versions { get; set; } = new();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private sealed class KeyVersionRecord
 | 
			
		||||
    {
 | 
			
		||||
        public string VersionId { get; set; } = string.Empty;
 | 
			
		||||
        public KmsKeyState State { get; set; } = KmsKeyState.Active;
 | 
			
		||||
        public DateTimeOffset CreatedAt { get; set; }
 | 
			
		||||
        public DateTimeOffset? DeactivatedAt { get; set; }
 | 
			
		||||
        public string PublicKey { get; set; } = string.Empty;
 | 
			
		||||
        public string FileName { get; set; } = string.Empty;
 | 
			
		||||
        public string CurveName { get; set; } = string.Empty;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private sealed record KeyEnvelope(
 | 
			
		||||
        string Ciphertext,
 | 
			
		||||
        string Nonce,
 | 
			
		||||
        string Tag,
 | 
			
		||||
        string Salt);
 | 
			
		||||
 | 
			
		||||
    private sealed record EcdsaKeyData(byte[] PrivateBlob, string PublicKey, string Curve);
 | 
			
		||||
 | 
			
		||||
    private sealed class EcdsaPrivateKeyRecord
 | 
			
		||||
    {
 | 
			
		||||
        public string Curve { get; set; } = string.Empty;
 | 
			
		||||
        public string D { get; set; } = string.Empty;
 | 
			
		||||
        public string Qx { get; set; } = string.Empty;
 | 
			
		||||
        public string Qy { get; set; } = string.Empty;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static ECCurve ResolveCurve(string curveName) => curveName switch
 | 
			
		||||
    {
 | 
			
		||||
        "nistP256" or "P-256" or "ES256" => ECCurve.NamedCurves.nistP256,
 | 
			
		||||
        _ => throw new NotSupportedException($"Curve '{curveName}' is not supported."),
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    public void Dispose() => _mutex.Dispose();
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										27
									
								
								src/__Libraries/StellaOps.Cryptography.Kms/FileKmsOptions.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/__Libraries/StellaOps.Cryptography.Kms/FileKmsOptions.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,27 @@
 | 
			
		||||
namespace StellaOps.Cryptography.Kms;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Options for the <see cref="FileKmsClient"/>.
 | 
			
		||||
/// </summary>
 | 
			
		||||
public sealed class FileKmsOptions
 | 
			
		||||
{
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Root directory for storing key material.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public string RootPath { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Password used to encrypt private key material at rest.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public required string Password { get; set; }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Signing algorithm identifier (default ED25519).
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public string Algorithm { get; set; } = KmsAlgorithms.Es256;
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// PBKDF2 iteration count for envelope encryption.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public int KeyDerivationIterations { get; set; } = 100_000;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										51
									
								
								src/__Libraries/StellaOps.Cryptography.Kms/IKmsClient.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								src/__Libraries/StellaOps.Cryptography.Kms/IKmsClient.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,51 @@
 | 
			
		||||
using System.Threading;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Cryptography.Kms;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Provides signing key operations backed by a key management system (KMS).
 | 
			
		||||
/// </summary>
 | 
			
		||||
public interface IKmsClient
 | 
			
		||||
{
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Signs the supplied digest with the specified key version.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    Task<KmsSignResult> SignAsync(
 | 
			
		||||
        string keyId,
 | 
			
		||||
        string? keyVersion,
 | 
			
		||||
        ReadOnlyMemory<byte> data,
 | 
			
		||||
        CancellationToken cancellationToken = default);
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Verifies a signature produced by <see cref="SignAsync"/>.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    Task<bool> VerifyAsync(
 | 
			
		||||
        string keyId,
 | 
			
		||||
        string? keyVersion,
 | 
			
		||||
        ReadOnlyMemory<byte> data,
 | 
			
		||||
        ReadOnlyMemory<byte> signature,
 | 
			
		||||
        CancellationToken cancellationToken = default);
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Retrieves metadata for the current key and versions.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    Task<KmsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken = default);
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Exports the key material required for local verification.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    Task<KmsKeyMaterial> ExportAsync(
 | 
			
		||||
        string keyId,
 | 
			
		||||
        string? keyVersion,
 | 
			
		||||
        CancellationToken cancellationToken = default);
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Generates a new active key version for the specified key.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    Task<KmsKeyMetadata> RotateAsync(string keyId, CancellationToken cancellationToken = default);
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Revokes a key, preventing future signing operations.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    Task RevokeAsync(string keyId, CancellationToken cancellationToken = default);
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,9 @@
 | 
			
		||||
namespace StellaOps.Cryptography.Kms;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Supported algorithm identifiers for the KMS abstraction.
 | 
			
		||||
/// </summary>
 | 
			
		||||
public static class KmsAlgorithms
 | 
			
		||||
{
 | 
			
		||||
    public const string Es256 = "ES256";
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										120
									
								
								src/__Libraries/StellaOps.Cryptography.Kms/KmsCryptoProvider.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								src/__Libraries/StellaOps.Cryptography.Kms/KmsCryptoProvider.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,120 @@
 | 
			
		||||
using System.Collections.Concurrent;
 | 
			
		||||
using System.Security.Cryptography;
 | 
			
		||||
using Microsoft.IdentityModel.Tokens;
 | 
			
		||||
using StellaOps.Cryptography;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Cryptography.Kms;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Crypto provider that delegates signing operations to a KMS backend.
 | 
			
		||||
/// </summary>
 | 
			
		||||
public sealed class KmsCryptoProvider : ICryptoProvider
 | 
			
		||||
{
 | 
			
		||||
    private readonly IKmsClient _kmsClient;
 | 
			
		||||
    private readonly ConcurrentDictionary<string, KmsSigningRegistration> _registrations = new(StringComparer.OrdinalIgnoreCase);
 | 
			
		||||
 | 
			
		||||
    public KmsCryptoProvider(IKmsClient kmsClient)
 | 
			
		||||
        => _kmsClient = kmsClient ?? throw new ArgumentNullException(nameof(kmsClient));
 | 
			
		||||
 | 
			
		||||
    public string Name => "kms";
 | 
			
		||||
 | 
			
		||||
    public bool Supports(CryptoCapability capability, string algorithmId)
 | 
			
		||||
    {
 | 
			
		||||
        if (!string.Equals(algorithmId, KmsAlgorithms.Es256, StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
        {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return capability is CryptoCapability.Signing or CryptoCapability.Verification;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public IPasswordHasher GetPasswordHasher(string algorithmId)
 | 
			
		||||
        => throw new InvalidOperationException($"Provider '{Name}' does not support password hashing.");
 | 
			
		||||
 | 
			
		||||
    public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(keyReference);
 | 
			
		||||
 | 
			
		||||
        if (!Supports(CryptoCapability.Signing, algorithmId))
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException($"Signing algorithm '{algorithmId}' is not supported by provider '{Name}'.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!_registrations.TryGetValue(keyReference.KeyId, out var registration))
 | 
			
		||||
        {
 | 
			
		||||
            throw new KeyNotFoundException($"Signing key '{keyReference.KeyId}' is not registered with provider '{Name}'.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return new KmsSigner(_kmsClient, registration);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public void UpsertSigningKey(CryptoSigningKey signingKey)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(signingKey);
 | 
			
		||||
 | 
			
		||||
        if (!string.Equals(signingKey.AlgorithmId, KmsAlgorithms.Es256, StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException($"Provider '{Name}' only supports {KmsAlgorithms.Es256} signing keys.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (signingKey.Metadata is null ||
 | 
			
		||||
            !signingKey.Metadata.TryGetValue(KmsMetadataKeys.Version, out var versionId) ||
 | 
			
		||||
            string.IsNullOrWhiteSpace(versionId))
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("KMS signing keys must include metadata entry 'kms.version'.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var registration = new KmsSigningRegistration(signingKey.Reference.KeyId, versionId!, signingKey.AlgorithmId);
 | 
			
		||||
        _registrations.AddOrUpdate(signingKey.Reference.KeyId, registration, (_, _) => registration);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public bool RemoveSigningKey(string keyId)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(keyId))
 | 
			
		||||
        {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return _registrations.TryRemove(keyId, out _);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public IReadOnlyCollection<CryptoSigningKey> GetSigningKeys()
 | 
			
		||||
    {
 | 
			
		||||
        var list = new List<CryptoSigningKey>();
 | 
			
		||||
        foreach (var registration in _registrations.Values)
 | 
			
		||||
        {
 | 
			
		||||
            var material = _kmsClient.ExportAsync(registration.KeyId, registration.VersionId).GetAwaiter().GetResult();
 | 
			
		||||
            var parameters = new ECParameters
 | 
			
		||||
            {
 | 
			
		||||
                Curve = ECCurve.NamedCurves.nistP256,
 | 
			
		||||
                D = material.D,
 | 
			
		||||
                Q = new ECPoint
 | 
			
		||||
                {
 | 
			
		||||
                    X = material.Qx,
 | 
			
		||||
                    Y = material.Qy,
 | 
			
		||||
                },
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
 | 
			
		||||
            {
 | 
			
		||||
                [KmsMetadataKeys.Version] = material.VersionId
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            list.Add(new CryptoSigningKey(
 | 
			
		||||
                new CryptoKeyReference(material.KeyId, Name),
 | 
			
		||||
                material.Algorithm,
 | 
			
		||||
                in parameters,
 | 
			
		||||
                material.CreatedAt,
 | 
			
		||||
                metadata: metadata));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return list;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    internal static class KmsMetadataKeys
 | 
			
		||||
    {
 | 
			
		||||
        public const string Version = "kms.version";
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
internal sealed record KmsSigningRegistration(string KeyId, string VersionId, string Algorithm);
 | 
			
		||||
							
								
								
									
										14
									
								
								src/__Libraries/StellaOps.Cryptography.Kms/KmsKeyMaterial.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/__Libraries/StellaOps.Cryptography.Kms/KmsKeyMaterial.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,14 @@
 | 
			
		||||
namespace StellaOps.Cryptography.Kms;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Represents exported key material for verification and registration.
 | 
			
		||||
/// </summary>
 | 
			
		||||
public sealed record KmsKeyMaterial(
 | 
			
		||||
    string KeyId,
 | 
			
		||||
    string VersionId,
 | 
			
		||||
    string Algorithm,
 | 
			
		||||
    string Curve,
 | 
			
		||||
    byte[] D,
 | 
			
		||||
    byte[] Qx,
 | 
			
		||||
    byte[] Qy,
 | 
			
		||||
    DateTimeOffset CreatedAt);
 | 
			
		||||
							
								
								
									
										24
									
								
								src/__Libraries/StellaOps.Cryptography.Kms/KmsKeyMetadata.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/__Libraries/StellaOps.Cryptography.Kms/KmsKeyMetadata.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,24 @@
 | 
			
		||||
using System.Collections.Immutable;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Cryptography.Kms;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Describes a logical KMS key and its versions.
 | 
			
		||||
/// </summary>
 | 
			
		||||
public sealed record KmsKeyMetadata(
 | 
			
		||||
    string KeyId,
 | 
			
		||||
    string Algorithm,
 | 
			
		||||
    KmsKeyState State,
 | 
			
		||||
    DateTimeOffset CreatedAt,
 | 
			
		||||
    ImmutableArray<KmsKeyVersionMetadata> Versions);
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Describes a specific key version.
 | 
			
		||||
/// </summary>
 | 
			
		||||
public sealed record KmsKeyVersionMetadata(
 | 
			
		||||
    string VersionId,
 | 
			
		||||
    KmsKeyState State,
 | 
			
		||||
    DateTimeOffset CreatedAt,
 | 
			
		||||
    DateTimeOffset? DeactivatedAt,
 | 
			
		||||
    string PublicKey,
 | 
			
		||||
    string Curve);
 | 
			
		||||
							
								
								
									
										11
									
								
								src/__Libraries/StellaOps.Cryptography.Kms/KmsKeyState.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/__Libraries/StellaOps.Cryptography.Kms/KmsKeyState.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
namespace StellaOps.Cryptography.Kms;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Represents the lifecycle state of a KMS key or key version.
 | 
			
		||||
/// </summary>
 | 
			
		||||
public enum KmsKeyState
 | 
			
		||||
{
 | 
			
		||||
    Active = 0,
 | 
			
		||||
    PendingRotation = 1,
 | 
			
		||||
    Revoked = 2,
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										10
									
								
								src/__Libraries/StellaOps.Cryptography.Kms/KmsSignResult.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/__Libraries/StellaOps.Cryptography.Kms/KmsSignResult.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
			
		||||
namespace StellaOps.Cryptography.Kms;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Represents the output of a signing operation.
 | 
			
		||||
/// </summary>
 | 
			
		||||
public sealed record KmsSignResult(
 | 
			
		||||
    string KeyId,
 | 
			
		||||
    string VersionId,
 | 
			
		||||
    string Algorithm,
 | 
			
		||||
    byte[] Signature);
 | 
			
		||||
							
								
								
									
										55
									
								
								src/__Libraries/StellaOps.Cryptography.Kms/KmsSigner.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								src/__Libraries/StellaOps.Cryptography.Kms/KmsSigner.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,55 @@
 | 
			
		||||
using System.Security.Cryptography;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using Microsoft.IdentityModel.Tokens;
 | 
			
		||||
using StellaOps.Cryptography;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Cryptography.Kms;
 | 
			
		||||
 | 
			
		||||
internal sealed class KmsSigner : ICryptoSigner
 | 
			
		||||
{
 | 
			
		||||
    private readonly IKmsClient _client;
 | 
			
		||||
    private readonly string _keyId;
 | 
			
		||||
    private readonly string _versionId;
 | 
			
		||||
    private readonly string _algorithm;
 | 
			
		||||
 | 
			
		||||
    public KmsSigner(IKmsClient client, KmsSigningRegistration registration)
 | 
			
		||||
    {
 | 
			
		||||
        _client = client;
 | 
			
		||||
        _keyId = registration.KeyId;
 | 
			
		||||
        _versionId = registration.VersionId;
 | 
			
		||||
        _algorithm = registration.Algorithm;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public string KeyId => _keyId;
 | 
			
		||||
 | 
			
		||||
    public string AlgorithmId => _algorithm;
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<byte[]> SignAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default)
 | 
			
		||||
    {
 | 
			
		||||
        var result = await _client.SignAsync(_keyId, _versionId, data, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        return result.Signature;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public ValueTask<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken = default)
 | 
			
		||||
        => new(_client.VerifyAsync(_keyId, _versionId, data, signature, cancellationToken));
 | 
			
		||||
 | 
			
		||||
    public JsonWebKey ExportPublicJsonWebKey()
 | 
			
		||||
    {
 | 
			
		||||
        var material = _client.ExportAsync(_keyId, _versionId).GetAwaiter().GetResult();
 | 
			
		||||
        var jwk = new JsonWebKey
 | 
			
		||||
        {
 | 
			
		||||
            Kid = material.KeyId,
 | 
			
		||||
            Alg = material.Algorithm,
 | 
			
		||||
            Kty = JsonWebAlgorithmsKeyTypes.EllipticCurve,
 | 
			
		||||
            Use = JsonWebKeyUseNames.Sig,
 | 
			
		||||
            Crv = JsonWebKeyECTypes.P256,
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        jwk.KeyOps.Add("sign");
 | 
			
		||||
        jwk.KeyOps.Add("verify");
 | 
			
		||||
        jwk.X = Base64UrlEncoder.Encode(material.Qx);
 | 
			
		||||
        jwk.Y = Base64UrlEncoder.Encode(material.Qy);
 | 
			
		||||
        return jwk;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,32 @@
 | 
			
		||||
using Microsoft.Extensions.DependencyInjection;
 | 
			
		||||
using Microsoft.Extensions.DependencyInjection.Extensions;
 | 
			
		||||
using Microsoft.Extensions.Options;
 | 
			
		||||
using StellaOps.Cryptography;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Cryptography.Kms;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Dependency injection helpers for the KMS client and crypto provider.
 | 
			
		||||
/// </summary>
 | 
			
		||||
public static class ServiceCollectionExtensions
 | 
			
		||||
{
 | 
			
		||||
    public static IServiceCollection AddFileKms(
 | 
			
		||||
        this IServiceCollection services,
 | 
			
		||||
        Action<FileKmsOptions> configure)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(services);
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(configure);
 | 
			
		||||
 | 
			
		||||
        services.Configure(configure);
 | 
			
		||||
 | 
			
		||||
        services.TryAddSingleton<IKmsClient>(sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var options = sp.GetRequiredService<IOptions<FileKmsOptions>>().Value;
 | 
			
		||||
            return new FileKmsClient(options);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, KmsCryptoProvider>());
 | 
			
		||||
 | 
			
		||||
        return services;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,14 @@
 | 
			
		||||
<Project Sdk="Microsoft.NET.Sdk">
 | 
			
		||||
  <PropertyGroup>
 | 
			
		||||
    <TargetFramework>net10.0</TargetFramework>
 | 
			
		||||
    <ImplicitUsings>enable</ImplicitUsings>
 | 
			
		||||
    <Nullable>enable</Nullable>
 | 
			
		||||
  </PropertyGroup>
 | 
			
		||||
  <ItemGroup>
 | 
			
		||||
    <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
 | 
			
		||||
    <PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0" />
 | 
			
		||||
  </ItemGroup>
 | 
			
		||||
  <ItemGroup>
 | 
			
		||||
    <ProjectReference Include="../StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
 | 
			
		||||
  </ItemGroup>
 | 
			
		||||
</Project>
 | 
			
		||||
@@ -3,7 +3,7 @@
 | 
			
		||||
## Sprint 72 – Abstractions & File Driver
 | 
			
		||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
 | 
			
		||||
|----|--------|----------|------------|-------------|---------------|
 | 
			
		||||
| KMS-72-001 | TODO | 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. |
 | 
			
		||||
| 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. |
 | 
			
		||||
| 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. |
 | 
			
		||||
 | 
			
		||||
## Sprint 73 – Cloud & HSM Integration
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,112 @@
 | 
			
		||||
using System.Security.Cryptography;
 | 
			
		||||
using StellaOps.Cryptography.Kms;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Cryptography.Kms.Tests;
 | 
			
		||||
 | 
			
		||||
public sealed class FileKmsClientTests : IDisposable
 | 
			
		||||
{
 | 
			
		||||
    private readonly string _rootPath;
 | 
			
		||||
 | 
			
		||||
    public FileKmsClientTests()
 | 
			
		||||
    {
 | 
			
		||||
        _rootPath = Path.Combine(Path.GetTempPath(), $"kms-tests-{Guid.NewGuid():N}");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [Fact]
 | 
			
		||||
    public async Task RotateSignVerifyLifecycle_Works()
 | 
			
		||||
    {
 | 
			
		||||
        using var client = CreateClient();
 | 
			
		||||
        var keyId = "kms-test-key";
 | 
			
		||||
 | 
			
		||||
        // Initial rotate creates the key.
 | 
			
		||||
        var metadata = await client.RotateAsync(keyId);
 | 
			
		||||
        Assert.Equal(keyId, metadata.KeyId);
 | 
			
		||||
        Assert.Single(metadata.Versions);
 | 
			
		||||
        Assert.Equal(KmsKeyState.Active, metadata.State);
 | 
			
		||||
        var version = metadata.Versions[0];
 | 
			
		||||
        Assert.Equal(KmsKeyState.Active, version.State);
 | 
			
		||||
 | 
			
		||||
        var firstData = RandomNumberGenerator.GetBytes(256);
 | 
			
		||||
        var firstSignature = await client.SignAsync(keyId, null, firstData);
 | 
			
		||||
        Assert.Equal(keyId, firstSignature.KeyId);
 | 
			
		||||
        Assert.Equal(KmsAlgorithms.Es256, firstSignature.Algorithm);
 | 
			
		||||
        Assert.True(await client.VerifyAsync(keyId, firstSignature.VersionId, firstData, firstSignature.Signature));
 | 
			
		||||
 | 
			
		||||
        // Rotate again and ensure metadata reflects both versions.
 | 
			
		||||
        var rotated = await client.RotateAsync(keyId);
 | 
			
		||||
        Assert.Equal(2, rotated.Versions.Length);
 | 
			
		||||
        var activeVersion = rotated.Versions.Single(v => v.State == KmsKeyState.Active);
 | 
			
		||||
        Assert.Equal(rotated.Versions.Max(v => v.VersionId), activeVersion.VersionId);
 | 
			
		||||
        var previousVersion = rotated.Versions.Single(v => v.State != KmsKeyState.Active);
 | 
			
		||||
        Assert.Equal(KmsKeyState.PendingRotation, previousVersion.State);
 | 
			
		||||
 | 
			
		||||
        var newData = RandomNumberGenerator.GetBytes(128);
 | 
			
		||||
        var activeSignature = await client.SignAsync(keyId, null, newData);
 | 
			
		||||
        Assert.Equal(activeVersion.VersionId, activeSignature.VersionId);
 | 
			
		||||
        Assert.True(await client.VerifyAsync(keyId, null, newData, activeSignature.Signature));
 | 
			
		||||
 | 
			
		||||
        // Explicit version verify should still pass for previous version using the old signature.
 | 
			
		||||
        Assert.True(await client.VerifyAsync(keyId, previousVersion.VersionId, firstData, firstSignature.Signature));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [Fact]
 | 
			
		||||
    public async Task RevokePreventsSigning()
 | 
			
		||||
    {
 | 
			
		||||
        using var client = CreateClient();
 | 
			
		||||
        var keyId = "kms-revoke";
 | 
			
		||||
 | 
			
		||||
        await client.RotateAsync(keyId);
 | 
			
		||||
        await client.RevokeAsync(keyId);
 | 
			
		||||
 | 
			
		||||
        var metadata = await client.GetMetadataAsync(keyId);
 | 
			
		||||
        Assert.Equal(KmsKeyState.Revoked, metadata.State);
 | 
			
		||||
        Assert.All(metadata.Versions, v => Assert.Equal(KmsKeyState.Revoked, v.State));
 | 
			
		||||
 | 
			
		||||
        var data = RandomNumberGenerator.GetBytes(32);
 | 
			
		||||
        await Assert.ThrowsAsync<InvalidOperationException>(() => client.SignAsync(keyId, null, data));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [Fact]
 | 
			
		||||
    public async Task ExportAsync_ReturnsKeyMaterial()
 | 
			
		||||
    {
 | 
			
		||||
        using var client = CreateClient();
 | 
			
		||||
        var keyId = "kms-export";
 | 
			
		||||
 | 
			
		||||
        await client.RotateAsync(keyId);
 | 
			
		||||
        var material = await client.ExportAsync(keyId, null);
 | 
			
		||||
 | 
			
		||||
        Assert.Equal(keyId, material.KeyId);
 | 
			
		||||
        Assert.Equal(KmsAlgorithms.Es256, material.Algorithm);
 | 
			
		||||
        Assert.Equal("nistP256", material.Curve);
 | 
			
		||||
        Assert.NotEmpty(material.D);
 | 
			
		||||
        Assert.NotEmpty(material.Qx);
 | 
			
		||||
        Assert.NotEmpty(material.Qy);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private FileKmsClient CreateClient()
 | 
			
		||||
    {
 | 
			
		||||
        var options = new FileKmsOptions
 | 
			
		||||
        {
 | 
			
		||||
            RootPath = _rootPath,
 | 
			
		||||
            Password = "P@ssw0rd!",
 | 
			
		||||
            Algorithm = KmsAlgorithms.Es256,
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        return new FileKmsClient(options);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public void Dispose()
 | 
			
		||||
    {
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            if (Directory.Exists(_rootPath))
 | 
			
		||||
            {
 | 
			
		||||
                Directory.Delete(_rootPath, recursive: true);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        catch
 | 
			
		||||
        {
 | 
			
		||||
            // ignore cleanup errors
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,20 @@
 | 
			
		||||
<Project Sdk="Microsoft.NET.Sdk">
 | 
			
		||||
  <PropertyGroup>
 | 
			
		||||
    <TargetFramework>net10.0</TargetFramework>
 | 
			
		||||
    <ImplicitUsings>enable</ImplicitUsings>
 | 
			
		||||
    <Nullable>enable</Nullable>
 | 
			
		||||
    <UseConcelierTestInfra>false</UseConcelierTestInfra>
 | 
			
		||||
  </PropertyGroup>
 | 
			
		||||
  <ItemGroup>
 | 
			
		||||
    <PackageReference Include="coverlet.collector" Version="6.0.4" />
 | 
			
		||||
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
 | 
			
		||||
    <PackageReference Include="xunit" Version="2.9.2" />
 | 
			
		||||
    <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
 | 
			
		||||
  </ItemGroup>
 | 
			
		||||
  <ItemGroup>
 | 
			
		||||
    <Using Include="Xunit" />
 | 
			
		||||
  </ItemGroup>
 | 
			
		||||
  <ItemGroup>
 | 
			
		||||
    <ProjectReference Include="../../StellaOps.Cryptography.Kms/StellaOps.Cryptography.Kms.csproj" />
 | 
			
		||||
  </ItemGroup>
 | 
			
		||||
</Project>
 | 
			
		||||
		Reference in New Issue
	
	Block a user