This commit is contained in:
master
2026-02-04 19:59:20 +02:00
parent 557feefdc3
commit 5548cf83bf
1479 changed files with 53557 additions and 40339 deletions

View File

@@ -0,0 +1,36 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class AwsKmsClient
{
private async Task<AwsKeyMetadata> GetCachedMetadataAsync(string keyId, CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
if (_metadataCache.TryGetValue(keyId, out var cached) && cached.ExpiresAt > now)
{
return cached.Metadata;
}
var metadata = await _facade.GetMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
var entry = new CachedMetadata(metadata, now.Add(_metadataCacheDuration));
_metadataCache[keyId] = entry;
return metadata;
}
private async Task<AwsPublicKeyMaterial> GetCachedPublicKeyAsync(string resource, CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
if (_publicKeyCache.TryGetValue(resource, out var cached) && cached.ExpiresAt > now)
{
return cached.Material;
}
var material = await _facade.GetPublicKeyAsync(resource, cancellationToken).ConfigureAwait(false);
var entry = new CachedPublicKey(material, now.Add(_publicKeyCacheDuration));
_publicKeyCache[resource] = entry;
return material;
}
}

View File

@@ -0,0 +1,68 @@
using Microsoft.IdentityModel.Tokens;
using System;
using System.Security.Cryptography;
namespace StellaOps.Cryptography.Kms;
public sealed partial class AwsKmsClient
{
private static byte[] ComputeSha256(ReadOnlyMemory<byte> data)
{
var digest = new byte[32];
if (!SHA256.TryHashData(data.Span, digest, out _))
{
throw new InvalidOperationException("Failed to hash payload with SHA-256.");
}
return digest;
}
private static string ResolveResource(string keyId, string? version)
=> string.IsNullOrWhiteSpace(version) ? keyId : version;
private static string ResolveCurveName(string curve)
{
if (string.Equals(curve, "ECC_NIST_P256", StringComparison.OrdinalIgnoreCase) ||
string.Equals(curve, "P-256", StringComparison.OrdinalIgnoreCase))
{
return JsonWebKeyECTypes.P256;
}
if (string.Equals(curve, "ECC_NIST_P384", StringComparison.OrdinalIgnoreCase) ||
string.Equals(curve, "P-384", StringComparison.OrdinalIgnoreCase))
{
return JsonWebKeyECTypes.P384;
}
if (string.Equals(curve, "ECC_NIST_P521", StringComparison.OrdinalIgnoreCase) ||
string.Equals(curve, "P-521", StringComparison.OrdinalIgnoreCase))
{
return JsonWebKeyECTypes.P521;
}
if (string.Equals(curve, "SECP256K1", StringComparison.OrdinalIgnoreCase) ||
string.Equals(curve, "ECC_SECG_P256K1", StringComparison.OrdinalIgnoreCase))
{
return "secp256k1";
}
return curve;
}
private static KmsKeyState MapState(AwsKeyStatus status)
=> status switch
{
AwsKeyStatus.Enabled => KmsKeyState.Active,
AwsKeyStatus.PendingImport or AwsKeyStatus.PendingUpdate => KmsKeyState.PendingRotation,
AwsKeyStatus.Disabled or AwsKeyStatus.PendingDeletion or AwsKeyStatus.Unavailable => KmsKeyState.Revoked,
_ => KmsKeyState.Active,
};
private void ThrowIfDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(AwsKmsClient));
}
}
}

View File

@@ -0,0 +1,63 @@
using System;
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class AwsKmsClient
{
public async Task<KmsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
var metadata = await GetCachedMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
var publicKey = await GetCachedPublicKeyAsync(metadata.KeyId, cancellationToken).ConfigureAwait(false);
var versionState = MapState(metadata.Status);
var versionMetadata = ImmutableArray.Create(
new KmsKeyVersionMetadata(
publicKey.VersionId,
versionState,
metadata.CreatedAt,
null,
Convert.ToBase64String(publicKey.SubjectPublicKeyInfo),
ResolveCurveName(publicKey.Curve)));
return new KmsKeyMetadata(
metadata.KeyId,
KmsAlgorithms.Es256,
versionState,
metadata.CreatedAt,
versionMetadata);
}
public async Task<KmsKeyMaterial> ExportAsync(
string keyId,
string? keyVersion,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
var metadata = await GetCachedMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
var resource = ResolveResource(metadata.KeyId, keyVersion);
var publicKey = await GetCachedPublicKeyAsync(resource, cancellationToken).ConfigureAwait(false);
using var ecdsa = ECDsa.Create();
ecdsa.ImportSubjectPublicKeyInfo(publicKey.SubjectPublicKeyInfo, out _);
var parameters = ecdsa.ExportParameters(false);
return new KmsKeyMaterial(
metadata.KeyId,
publicKey.VersionId,
KmsAlgorithms.Es256,
ResolveCurveName(publicKey.Curve),
Array.Empty<byte>(),
parameters.Q.X ?? throw new InvalidOperationException("Public key missing X coordinate."),
parameters.Q.Y ?? throw new InvalidOperationException("Public key missing Y coordinate."),
metadata.CreatedAt);
}
}

View File

@@ -0,0 +1,10 @@
using System;
namespace StellaOps.Cryptography.Kms;
public sealed partial class AwsKmsClient
{
private sealed record CachedMetadata(AwsKeyMetadata Metadata, DateTimeOffset ExpiresAt);
private sealed record CachedPublicKey(AwsPublicKeyMaterial Material, DateTimeOffset ExpiresAt);
}

View File

@@ -0,0 +1,66 @@
using System;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class AwsKmsClient
{
public async Task<KmsSignResult> SignAsync(
string keyId,
string? keyVersion,
ReadOnlyMemory<byte> data,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
if (data.IsEmpty)
{
throw new ArgumentException("Signing payload cannot be empty.", nameof(data));
}
var digest = ComputeSha256(data);
try
{
var resource = ResolveResource(keyId, keyVersion);
var result = await _facade.SignAsync(resource, digest, cancellationToken).ConfigureAwait(false);
return new KmsSignResult(
keyId,
string.IsNullOrWhiteSpace(result.VersionId) ? resource : result.VersionId,
KmsAlgorithms.Es256,
result.Signature);
}
finally
{
CryptographicOperations.ZeroMemory(digest.AsSpan());
}
}
public async Task<bool> VerifyAsync(
string keyId,
string? keyVersion,
ReadOnlyMemory<byte> data,
ReadOnlyMemory<byte> signature,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
if (data.IsEmpty || signature.IsEmpty)
{
return false;
}
var digest = ComputeSha256(data);
try
{
var resource = ResolveResource(keyId, keyVersion);
return await _facade.VerifyAsync(resource, digest, signature, cancellationToken).ConfigureAwait(false);
}
finally
{
CryptographicOperations.ZeroMemory(digest.AsSpan());
}
}
}

View File

@@ -1,21 +1,19 @@
using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.Options;
using System;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
/// <summary>
/// AWS KMS implementation of <see cref="IKmsClient"/>.
/// </summary>
public sealed class AwsKmsClient : IKmsClient, IDisposable
public sealed partial class AwsKmsClient : IKmsClient, IDisposable
{
private readonly IAwsKmsFacade _facade;
private readonly TimeProvider _timeProvider;
private readonly TimeSpan _metadataCacheDuration;
private readonly TimeSpan _publicKeyCacheDuration;
private readonly ConcurrentDictionary<string, CachedMetadata> _metadataCache = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, CachedPublicKey> _publicKeyCache = new(StringComparer.Ordinal);
private bool _disposed;
@@ -30,114 +28,9 @@ public sealed class AwsKmsClient : IKmsClient, IDisposable
_publicKeyCacheDuration = options.PublicKeyCacheDuration;
}
public async Task<KmsSignResult> SignAsync(
string keyId,
string? keyVersion,
ReadOnlyMemory<byte> data,
CancellationToken cancellationToken = default)
public AwsKmsClient(IAwsKmsFacade facade, IOptions<AwsKmsOptions> options, TimeProvider timeProvider)
: this(facade, options?.Value ?? new AwsKmsOptions(), timeProvider)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
if (data.IsEmpty)
{
throw new ArgumentException("Signing payload cannot be empty.", nameof(data));
}
var digest = ComputeSha256(data);
try
{
var resource = ResolveResource(keyId, keyVersion);
var result = await _facade.SignAsync(resource, digest, cancellationToken).ConfigureAwait(false);
return new KmsSignResult(
keyId,
string.IsNullOrWhiteSpace(result.VersionId) ? resource : result.VersionId,
KmsAlgorithms.Es256,
result.Signature);
}
finally
{
CryptographicOperations.ZeroMemory(digest.AsSpan());
}
}
public async Task<bool> VerifyAsync(
string keyId,
string? keyVersion,
ReadOnlyMemory<byte> data,
ReadOnlyMemory<byte> signature,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
if (data.IsEmpty || signature.IsEmpty)
{
return false;
}
var digest = ComputeSha256(data);
try
{
var resource = ResolveResource(keyId, keyVersion);
return await _facade.VerifyAsync(resource, digest, signature, cancellationToken).ConfigureAwait(false);
}
finally
{
CryptographicOperations.ZeroMemory(digest.AsSpan());
}
}
public async Task<KmsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
var metadata = await GetCachedMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
var publicKey = await GetCachedPublicKeyAsync(metadata.KeyId, cancellationToken).ConfigureAwait(false);
var versionState = MapState(metadata.Status);
var versionMetadata = ImmutableArray.Create(
new KmsKeyVersionMetadata(
publicKey.VersionId,
versionState,
metadata.CreatedAt,
null,
Convert.ToBase64String(publicKey.SubjectPublicKeyInfo),
ResolveCurveName(publicKey.Curve)));
return new KmsKeyMetadata(
metadata.KeyId,
KmsAlgorithms.Es256,
versionState,
metadata.CreatedAt,
versionMetadata);
}
public async Task<KmsKeyMaterial> ExportAsync(
string keyId,
string? keyVersion,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
var metadata = await GetCachedMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
var resource = ResolveResource(metadata.KeyId, keyVersion);
var publicKey = await GetCachedPublicKeyAsync(resource, cancellationToken).ConfigureAwait(false);
using var ecdsa = ECDsa.Create();
ecdsa.ImportSubjectPublicKeyInfo(publicKey.SubjectPublicKeyInfo, out _);
var parameters = ecdsa.ExportParameters(false);
return new KmsKeyMaterial(
metadata.KeyId,
publicKey.VersionId,
KmsAlgorithms.Es256,
ResolveCurveName(publicKey.Curve),
Array.Empty<byte>(),
parameters.Q.X ?? throw new InvalidOperationException("Public key missing X coordinate."),
parameters.Q.Y ?? throw new InvalidOperationException("Public key missing Y coordinate."),
metadata.CreatedAt);
}
public Task<KmsKeyMetadata> RotateAsync(string keyId, CancellationToken cancellationToken = default)
@@ -156,96 +49,4 @@ public sealed class AwsKmsClient : IKmsClient, IDisposable
_disposed = true;
_facade.Dispose();
}
private async Task<AwsKeyMetadata> GetCachedMetadataAsync(string keyId, CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
if (_metadataCache.TryGetValue(keyId, out var cached) && cached.ExpiresAt > now)
{
return cached.Metadata;
}
var metadata = await _facade.GetMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
var entry = new CachedMetadata(metadata, now.Add(_metadataCacheDuration));
_metadataCache[keyId] = entry;
return metadata;
}
private async Task<AwsPublicKeyMaterial> GetCachedPublicKeyAsync(string resource, CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
if (_publicKeyCache.TryGetValue(resource, out var cached) && cached.ExpiresAt > now)
{
return cached.Material;
}
var material = await _facade.GetPublicKeyAsync(resource, cancellationToken).ConfigureAwait(false);
var entry = new CachedPublicKey(material, now.Add(_publicKeyCacheDuration));
_publicKeyCache[resource] = entry;
return material;
}
private static byte[] ComputeSha256(ReadOnlyMemory<byte> data)
{
var digest = new byte[32];
if (!SHA256.TryHashData(data.Span, digest, out _))
{
throw new InvalidOperationException("Failed to hash payload with SHA-256.");
}
return digest;
}
private static string ResolveResource(string keyId, string? version)
=> string.IsNullOrWhiteSpace(version) ? keyId : version;
private static string ResolveCurveName(string curve)
{
if (string.Equals(curve, "ECC_NIST_P256", StringComparison.OrdinalIgnoreCase) ||
string.Equals(curve, "P-256", StringComparison.OrdinalIgnoreCase))
{
return JsonWebKeyECTypes.P256;
}
if (string.Equals(curve, "ECC_NIST_P384", StringComparison.OrdinalIgnoreCase) ||
string.Equals(curve, "P-384", StringComparison.OrdinalIgnoreCase))
{
return JsonWebKeyECTypes.P384;
}
if (string.Equals(curve, "ECC_NIST_P521", StringComparison.OrdinalIgnoreCase) ||
string.Equals(curve, "P-521", StringComparison.OrdinalIgnoreCase))
{
return JsonWebKeyECTypes.P521;
}
if (string.Equals(curve, "SECP256K1", StringComparison.OrdinalIgnoreCase) ||
string.Equals(curve, "ECC_SECG_P256K1", StringComparison.OrdinalIgnoreCase))
{
return "secp256k1";
}
return curve;
}
private static KmsKeyState MapState(AwsKeyStatus status)
=> status switch
{
AwsKeyStatus.Enabled => KmsKeyState.Active,
AwsKeyStatus.PendingImport or AwsKeyStatus.PendingUpdate => KmsKeyState.PendingRotation,
AwsKeyStatus.Disabled or AwsKeyStatus.PendingDeletion or AwsKeyStatus.Unavailable => KmsKeyState.Revoked,
_ => KmsKeyState.Active,
};
private void ThrowIfDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(AwsKmsClient));
}
}
private sealed record CachedMetadata(AwsKeyMetadata Metadata, DateTimeOffset ExpiresAt);
private sealed record CachedPublicKey(AwsPublicKeyMaterial Material, DateTimeOffset ExpiresAt);
}
}

View File

@@ -0,0 +1,16 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public interface IAwsKmsFacade : IDisposable
{
Task<AwsSignResult> SignAsync(string keyResource, ReadOnlyMemory<byte> digest, CancellationToken cancellationToken);
Task<bool> VerifyAsync(string keyResource, ReadOnlyMemory<byte> digest, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken);
Task<AwsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken);
Task<AwsPublicKeyMaterial> GetPublicKeyAsync(string keyResource, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,14 @@
using System;
namespace StellaOps.Cryptography.Kms;
internal sealed partial class AwsKmsFacade
{
public void Dispose()
{
if (_ownsClient && _client is IDisposable disposable)
{
disposable.Dispose();
}
}
}

View File

@@ -0,0 +1,43 @@
using Amazon.KeyManagementService;
using Amazon.KeyManagementService.Model;
using System;
namespace StellaOps.Cryptography.Kms;
internal sealed partial class AwsKmsFacade
{
private static AwsKeyStatus MapStatus(KeyState? state)
{
var name = state?.ToString();
return name switch
{
"Enabled" => AwsKeyStatus.Enabled,
"Disabled" => AwsKeyStatus.Disabled,
"PendingDeletion" => AwsKeyStatus.PendingDeletion,
"PendingImport" => AwsKeyStatus.PendingImport,
"Unavailable" => AwsKeyStatus.Unavailable,
_ => AwsKeyStatus.Unspecified,
};
}
private static string ResolveCurve(GetPublicKeyResponse response)
{
if (response.KeySpec is not null)
{
var keySpecName = response.KeySpec.ToString();
if (!string.IsNullOrWhiteSpace(keySpecName))
{
return keySpecName switch
{
"ECC_NIST_P256" => "P-256",
"ECC_SECG_P256K1" => "secp256k1",
"ECC_NIST_P384" => "P-384",
"ECC_NIST_P521" => "P-521",
_ => keySpecName,
};
}
}
return "P-256";
}
}

View File

@@ -0,0 +1,44 @@
using Amazon.KeyManagementService.Model;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
internal sealed partial class AwsKmsFacade
{
public async Task<AwsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
var response = await _client.DescribeKeyAsync(new DescribeKeyRequest
{
KeyId = keyId,
}, cancellationToken).ConfigureAwait(false);
var metadata = response.KeyMetadata ?? throw new InvalidOperationException($"Key '{keyId}' was not found.");
var createdAt = metadata.CreationDate?.ToUniversalTime() ?? _timeProvider.GetUtcNow();
return new AwsKeyMetadata(
metadata.KeyId ?? keyId,
metadata.Arn ?? metadata.KeyId ?? keyId,
createdAt,
MapStatus(metadata.KeyState));
}
public async Task<AwsPublicKeyMaterial> GetPublicKeyAsync(string keyResource, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyResource);
var response = await _client.GetPublicKeyAsync(new GetPublicKeyRequest
{
KeyId = keyResource,
}, cancellationToken).ConfigureAwait(false);
var keyId = response.KeyId ?? keyResource;
var versionId = response.KeyId ?? keyResource;
var curve = ResolveCurve(response);
return new AwsPublicKeyMaterial(keyId, versionId, curve, response.PublicKey.ToArray());
}
}

View File

@@ -0,0 +1,20 @@
using System;
namespace StellaOps.Cryptography.Kms;
public sealed record AwsSignResult(string KeyResource, string VersionId, byte[] Signature);
public sealed record AwsKeyMetadata(string KeyId, string Arn, DateTimeOffset CreatedAt, AwsKeyStatus Status);
public enum AwsKeyStatus
{
Unspecified = 0,
Enabled = 1,
Disabled = 2,
PendingDeletion = 3,
PendingImport = 4,
PendingUpdate = 5,
Unavailable = 6,
}
public sealed record AwsPublicKeyMaterial(string KeyId, string VersionId, string Curve, byte[] SubjectPublicKeyInfo);

View File

@@ -0,0 +1,52 @@
using Amazon.KeyManagementService;
using Amazon.KeyManagementService.Model;
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
internal sealed partial class AwsKmsFacade
{
public async Task<AwsSignResult> SignAsync(string keyResource, ReadOnlyMemory<byte> digest, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyResource);
using var messageStream = new MemoryStream(digest.ToArray(), writable: false);
var request = new SignRequest
{
KeyId = keyResource,
SigningAlgorithm = SigningAlgorithmSpec.ECDSA_SHA_256,
MessageType = MessageType.DIGEST,
Message = messageStream,
};
var response = await _client.SignAsync(request, cancellationToken).ConfigureAwait(false);
var keyId = response.KeyId ?? keyResource;
return new AwsSignResult(keyId, keyId, response.Signature.ToArray());
}
public async Task<bool> VerifyAsync(string keyResource, ReadOnlyMemory<byte> digest, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyResource);
if (digest.IsEmpty || signature.IsEmpty)
{
return false;
}
using var messageStream = new MemoryStream(digest.ToArray(), writable: false);
using var signatureStream = new MemoryStream(signature.ToArray(), writable: false);
var request = new VerifyRequest
{
KeyId = keyResource,
SigningAlgorithm = SigningAlgorithmSpec.ECDSA_SHA_256,
MessageType = MessageType.DIGEST,
Message = messageStream,
Signature = signatureStream,
};
var response = await _client.VerifyAsync(request, cancellationToken).ConfigureAwait(false);
return response.SignatureValid ?? false;
}
}

View File

@@ -1,40 +1,11 @@
using Amazon;
using Amazon.KeyManagementService;
using Amazon.KeyManagementService.Model;
using System.IO;
using Microsoft.Extensions.Options;
using System;
namespace StellaOps.Cryptography.Kms;
public interface IAwsKmsFacade : IDisposable
{
Task<AwsSignResult> SignAsync(string keyResource, ReadOnlyMemory<byte> digest, CancellationToken cancellationToken);
Task<bool> VerifyAsync(string keyResource, ReadOnlyMemory<byte> digest, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken);
Task<AwsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken);
Task<AwsPublicKeyMaterial> GetPublicKeyAsync(string keyResource, CancellationToken cancellationToken);
}
public sealed record AwsSignResult(string KeyResource, string VersionId, byte[] Signature);
public sealed record AwsKeyMetadata(string KeyId, string Arn, DateTimeOffset CreatedAt, AwsKeyStatus Status);
public enum AwsKeyStatus
{
Unspecified = 0,
Enabled = 1,
Disabled = 2,
PendingDeletion = 3,
PendingImport = 4,
PendingUpdate = 5,
Unavailable = 6,
}
public sealed record AwsPublicKeyMaterial(string KeyId, string VersionId, string Curve, byte[] SubjectPublicKeyInfo);
internal sealed class AwsKmsFacade : IAwsKmsFacade
internal sealed partial class AwsKmsFacade : IAwsKmsFacade
{
private readonly IAmazonKeyManagementService _client;
private readonly bool _ownsClient;
@@ -62,129 +33,15 @@ internal sealed class AwsKmsFacade : IAwsKmsFacade
_ownsClient = true;
}
public AwsKmsFacade(IOptions<AwsKmsOptions> options, TimeProvider timeProvider)
: this(options?.Value ?? new AwsKmsOptions(), timeProvider)
{
}
public AwsKmsFacade(IAmazonKeyManagementService client, TimeProvider? timeProvider = null)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_timeProvider = timeProvider ?? TimeProvider.System;
_ownsClient = false;
}
public async Task<AwsSignResult> SignAsync(string keyResource, ReadOnlyMemory<byte> digest, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyResource);
using var messageStream = new MemoryStream(digest.ToArray(), writable: false);
var request = new SignRequest
{
KeyId = keyResource,
SigningAlgorithm = SigningAlgorithmSpec.ECDSA_SHA_256,
MessageType = MessageType.DIGEST,
Message = messageStream,
};
var response = await _client.SignAsync(request, cancellationToken).ConfigureAwait(false);
var keyId = response.KeyId ?? keyResource;
return new AwsSignResult(keyId, keyId, response.Signature.ToArray());
}
public async Task<bool> VerifyAsync(string keyResource, ReadOnlyMemory<byte> digest, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyResource);
if (digest.IsEmpty || signature.IsEmpty)
{
return false;
}
using var messageStream = new MemoryStream(digest.ToArray(), writable: false);
using var signatureStream = new MemoryStream(signature.ToArray(), writable: false);
var request = new VerifyRequest
{
KeyId = keyResource,
SigningAlgorithm = SigningAlgorithmSpec.ECDSA_SHA_256,
MessageType = MessageType.DIGEST,
Message = messageStream,
Signature = signatureStream,
};
var response = await _client.VerifyAsync(request, cancellationToken).ConfigureAwait(false);
return response.SignatureValid ?? false;
}
public async Task<AwsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
var response = await _client.DescribeKeyAsync(new DescribeKeyRequest
{
KeyId = keyId,
}, cancellationToken).ConfigureAwait(false);
var metadata = response.KeyMetadata ?? throw new InvalidOperationException($"Key '{keyId}' was not found.");
var createdAt = metadata.CreationDate?.ToUniversalTime() ?? _timeProvider.GetUtcNow();
return new AwsKeyMetadata(
metadata.KeyId ?? keyId,
metadata.Arn ?? metadata.KeyId ?? keyId,
createdAt,
MapStatus(metadata.KeyState));
}
public async Task<AwsPublicKeyMaterial> GetPublicKeyAsync(string keyResource, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyResource);
var response = await _client.GetPublicKeyAsync(new GetPublicKeyRequest
{
KeyId = keyResource,
}, cancellationToken).ConfigureAwait(false);
var keyId = response.KeyId ?? keyResource;
var versionId = response.KeyId ?? keyResource;
var curve = ResolveCurve(response);
return new AwsPublicKeyMaterial(keyId, versionId, curve, response.PublicKey.ToArray());
}
private static AwsKeyStatus MapStatus(KeyState? state)
{
var name = state?.ToString();
return name switch
{
"Enabled" => AwsKeyStatus.Enabled,
"Disabled" => AwsKeyStatus.Disabled,
"PendingDeletion" => AwsKeyStatus.PendingDeletion,
"PendingImport" => AwsKeyStatus.PendingImport,
"Unavailable" => AwsKeyStatus.Unavailable,
_ => AwsKeyStatus.Unspecified,
};
}
private static string ResolveCurve(GetPublicKeyResponse response)
{
if (response.KeySpec is not null)
{
var keySpecName = response.KeySpec.ToString();
if (!string.IsNullOrWhiteSpace(keySpecName))
{
return keySpecName switch
{
"ECC_NIST_P256" => "P-256",
"ECC_SECG_P256K1" => "secp256k1",
"ECC_NIST_P384" => "P-384",
"ECC_NIST_P521" => "P-521",
_ => keySpecName,
};
}
}
return "P-256";
}
public void Dispose()
{
if (_ownsClient && _client is IDisposable disposable)
{
disposable.Dispose();
}
}
}
}

View File

@@ -1,5 +1,3 @@
using System.Diagnostics.CodeAnalysis;
namespace StellaOps.Cryptography.Kms;
/// <summary>
@@ -7,8 +5,8 @@ namespace StellaOps.Cryptography.Kms;
/// </summary>
public sealed class AwsKmsOptions
{
private TimeSpan metadataCacheDuration = TimeSpan.FromMinutes(5);
private TimeSpan publicKeyCacheDuration = TimeSpan.FromMinutes(10);
private TimeSpan _metadataCacheDuration = TimeSpan.FromMinutes(5);
private TimeSpan _publicKeyCacheDuration = TimeSpan.FromMinutes(10);
/// <summary>
/// Gets or sets the AWS region identifier (e.g. <c>us-east-1</c>).
@@ -30,8 +28,8 @@ public sealed class AwsKmsOptions
/// </summary>
public TimeSpan MetadataCacheDuration
{
get => metadataCacheDuration;
set => metadataCacheDuration = EnsurePositive(value, TimeSpan.FromMinutes(5));
get => _metadataCacheDuration;
set => _metadataCacheDuration = EnsurePositive(value, TimeSpan.FromMinutes(5));
}
/// <summary>
@@ -39,16 +37,10 @@ public sealed class AwsKmsOptions
/// </summary>
public TimeSpan PublicKeyCacheDuration
{
get => publicKeyCacheDuration;
set => publicKeyCacheDuration = EnsurePositive(value, TimeSpan.FromMinutes(10));
get => _publicKeyCacheDuration;
set => _publicKeyCacheDuration = EnsurePositive(value, TimeSpan.FromMinutes(10));
}
/// <summary>
/// Gets or sets an optional factory that can provide a custom AWS facade. Primarily used for testing.
/// </summary>
public Func<IServiceProvider, IAwsKmsFacade>? FacadeFactory { get; set; }
private static TimeSpan EnsurePositive(TimeSpan value, TimeSpan @default)
=> value <= TimeSpan.Zero ? @default : value;
}

View File

@@ -0,0 +1,25 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class Fido2KmsClient
{
public async Task<KmsKeyMaterial> ExportAsync(string keyId, string? keyVersion, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
var metadata = await GetMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
return new KmsKeyMaterial(
metadata.KeyId,
metadata.KeyId,
metadata.Algorithm,
_curveName,
Array.Empty<byte>(),
_publicParameters.Q.X ?? throw new InvalidOperationException("FIDO2 public key missing X coordinate."),
_publicParameters.Q.Y ?? throw new InvalidOperationException("FIDO2 public key missing Y coordinate."),
_options.CreatedAt ?? _timeProvider.GetUtcNow());
}
}

View File

@@ -0,0 +1,39 @@
using Microsoft.IdentityModel.Tokens;
using System;
using System.Security.Cryptography;
namespace StellaOps.Cryptography.Kms;
public sealed partial class Fido2KmsClient
{
private static byte[] ComputeSha256(ReadOnlyMemory<byte> data)
{
var digest = new byte[32];
if (!SHA256.TryHashData(data.Span, digest, out _))
{
throw new InvalidOperationException("Failed to hash payload with SHA-256.");
}
return digest;
}
private void ThrowIfDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(Fido2KmsClient));
}
}
private static string ResolveCurveName(ECCurve curve)
{
var oid = curve.Oid?.Value;
return oid switch
{
"1.2.840.10045.3.1.7" => JsonWebKeyECTypes.P256,
"1.3.132.0.34" => JsonWebKeyECTypes.P384,
"1.3.132.0.35" => JsonWebKeyECTypes.P521,
_ => throw new InvalidOperationException($"Unsupported FIDO2 curve OID '{oid}'."),
};
}
}

View File

@@ -0,0 +1,19 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class Fido2KmsClient
{
public Task<KmsKeyMetadata> RotateAsync(string keyId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("FIDO2 credential rotation requires new enrolment.");
public Task RevokeAsync(string keyId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("FIDO2 credential revocation must be managed in the relying party.");
public void Dispose()
{
_disposed = true;
}
}

View File

@@ -0,0 +1,39 @@
using System;
using System.Collections.Immutable;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class Fido2KmsClient
{
public Task<KmsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
var now = _timeProvider.GetUtcNow();
if (_cachedMetadata is not null && _metadataExpiresAt > now)
{
return Task.FromResult(_cachedMetadata);
}
var createdAt = _options.CreatedAt ?? _timeProvider.GetUtcNow();
var version = new KmsKeyVersionMetadata(
_options.CredentialId,
KmsKeyState.Active,
createdAt,
null,
Convert.ToBase64String(_subjectPublicKeyInfo),
_curveName);
_cachedMetadata = new KmsKeyMetadata(
_options.CredentialId,
KmsAlgorithms.Es256,
KmsKeyState.Active,
createdAt,
ImmutableArray.Create(version));
_metadataExpiresAt = now.Add(_metadataCacheDuration);
return Task.FromResult(_cachedMetadata);
}
}

View File

@@ -0,0 +1,58 @@
using System;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class Fido2KmsClient
{
public async Task<KmsSignResult> SignAsync(
string keyId,
string? keyVersion,
ReadOnlyMemory<byte> data,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (data.IsEmpty)
{
throw new ArgumentException("Signing payload cannot be empty.", nameof(data));
}
var digest = ComputeSha256(data);
try
{
var signature = await _authenticator.SignAsync(_options.CredentialId, digest, cancellationToken).ConfigureAwait(false);
return new KmsSignResult(_options.CredentialId, _options.CredentialId, KmsAlgorithms.Es256, signature);
}
finally
{
CryptographicOperations.ZeroMemory(digest.AsSpan());
}
}
public Task<bool> VerifyAsync(
string keyId,
string? keyVersion,
ReadOnlyMemory<byte> data,
ReadOnlyMemory<byte> signature,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (data.IsEmpty || signature.IsEmpty)
{
return Task.FromResult(false);
}
var digest = ComputeSha256(data);
try
{
using var ecdsa = ECDsa.Create(_publicParameters);
return Task.FromResult(ecdsa.VerifyHash(digest, signature.ToArray()));
}
finally
{
CryptographicOperations.ZeroMemory(digest.AsSpan());
}
}
}

View File

@@ -1,6 +1,5 @@
using Microsoft.IdentityModel.Tokens;
using System.Collections.Immutable;
using Microsoft.Extensions.Options;
using System;
using System.Security.Cryptography;
namespace StellaOps.Cryptography.Kms;
@@ -8,7 +7,7 @@ namespace StellaOps.Cryptography.Kms;
/// <summary>
/// FIDO2-backed KMS client suitable for high-assurance interactive workflows.
/// </summary>
public sealed class Fido2KmsClient : IKmsClient
public sealed partial class Fido2KmsClient : IKmsClient
{
private readonly IFido2Authenticator _authenticator;
private readonly Fido2Options _options;
@@ -49,141 +48,8 @@ public sealed class Fido2KmsClient : IKmsClient
_curveName = ResolveCurveName(_publicParameters.Curve);
}
public async Task<KmsSignResult> SignAsync(
string keyId,
string? keyVersion,
ReadOnlyMemory<byte> data,
CancellationToken cancellationToken = default)
public Fido2KmsClient(IFido2Authenticator authenticator, IOptions<Fido2Options> options, TimeProvider timeProvider)
: this(authenticator, options?.Value ?? new Fido2Options(), timeProvider)
{
ThrowIfDisposed();
if (data.IsEmpty)
{
throw new ArgumentException("Signing payload cannot be empty.", nameof(data));
}
var digest = ComputeSha256(data);
try
{
var signature = await _authenticator.SignAsync(_options.CredentialId, digest, cancellationToken).ConfigureAwait(false);
return new KmsSignResult(_options.CredentialId, _options.CredentialId, KmsAlgorithms.Es256, signature);
}
finally
{
CryptographicOperations.ZeroMemory(digest.AsSpan());
}
}
public Task<bool> VerifyAsync(
string keyId,
string? keyVersion,
ReadOnlyMemory<byte> data,
ReadOnlyMemory<byte> signature,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (data.IsEmpty || signature.IsEmpty)
{
return Task.FromResult(false);
}
var digest = ComputeSha256(data);
try
{
using var ecdsa = ECDsa.Create(_publicParameters);
return Task.FromResult(ecdsa.VerifyHash(digest, signature.ToArray()));
}
finally
{
CryptographicOperations.ZeroMemory(digest.AsSpan());
}
}
public Task<KmsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
var now = _timeProvider.GetUtcNow();
if (_cachedMetadata is not null && _metadataExpiresAt > now)
{
return Task.FromResult(_cachedMetadata);
}
var createdAt = _options.CreatedAt ?? _timeProvider.GetUtcNow();
var version = new KmsKeyVersionMetadata(
_options.CredentialId,
KmsKeyState.Active,
createdAt,
null,
Convert.ToBase64String(_subjectPublicKeyInfo),
_curveName);
_cachedMetadata = new KmsKeyMetadata(
_options.CredentialId,
KmsAlgorithms.Es256,
KmsKeyState.Active,
createdAt,
ImmutableArray.Create(version));
_metadataExpiresAt = now.Add(_metadataCacheDuration);
return Task.FromResult(_cachedMetadata);
}
public async Task<KmsKeyMaterial> ExportAsync(string keyId, string? keyVersion, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
var metadata = await GetMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
return new KmsKeyMaterial(
metadata.KeyId,
metadata.KeyId,
metadata.Algorithm,
_curveName,
Array.Empty<byte>(),
_publicParameters.Q.X ?? throw new InvalidOperationException("FIDO2 public key missing X coordinate."),
_publicParameters.Q.Y ?? throw new InvalidOperationException("FIDO2 public key missing Y coordinate."),
_options.CreatedAt ?? _timeProvider.GetUtcNow());
}
public Task<KmsKeyMetadata> RotateAsync(string keyId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("FIDO2 credential rotation requires new enrolment.");
public Task RevokeAsync(string keyId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("FIDO2 credential revocation must be managed in the relying party.");
public void Dispose()
{
_disposed = true;
}
private static byte[] ComputeSha256(ReadOnlyMemory<byte> data)
{
var digest = new byte[32];
if (!SHA256.TryHashData(data.Span, digest, out _))
{
throw new InvalidOperationException("Failed to hash payload with SHA-256.");
}
return digest;
}
private void ThrowIfDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(Fido2KmsClient));
}
}
private static string ResolveCurveName(ECCurve curve)
{
var oid = curve.Oid?.Value;
return oid switch
{
"1.2.840.10045.3.1.7" => JsonWebKeyECTypes.P256,
"1.3.132.0.34" => JsonWebKeyECTypes.P384,
"1.3.132.0.35" => JsonWebKeyECTypes.P521,
_ => throw new InvalidOperationException($"Unsupported FIDO2 curve OID '{oid}'."),
};
}
}

View File

@@ -5,7 +5,7 @@ namespace StellaOps.Cryptography.Kms;
/// </summary>
public sealed class Fido2Options
{
private TimeSpan metadataCacheDuration = TimeSpan.FromMinutes(5);
private TimeSpan _metadataCacheDuration = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets or sets the relying party identifier (rpId) used when registering the credential.
@@ -33,13 +33,8 @@ public sealed class Fido2Options
/// </summary>
public TimeSpan MetadataCacheDuration
{
get => metadataCacheDuration;
set => metadataCacheDuration = value <= TimeSpan.Zero ? TimeSpan.FromMinutes(5) : value;
get => _metadataCacheDuration;
set => _metadataCacheDuration = value <= TimeSpan.Zero ? TimeSpan.FromMinutes(5) : value;
}
/// <summary>
/// Gets or sets an optional authenticator factory hook (mainly for testing or custom integrations).
/// </summary>
public Func<IServiceProvider, IFido2Authenticator>? AuthenticatorFactory { get; set; }
}

View File

@@ -0,0 +1,94 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Cryptography.Kms;
public sealed partial class FileKmsClient
{
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();
using var aesGcm = new AesGcm(key, tag.Length);
try
{
aesGcm.Encrypt(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];
using var aesGcm = new AesGcm(key, tag.Length);
aesGcm.Decrypt(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;
}
}
}

View File

@@ -0,0 +1,50 @@
using System;
using System.Security.Cryptography;
using System.Text.Json;
namespace StellaOps.Cryptography.Kms;
public sealed partial class FileKmsClient
{
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 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;
}
}

View File

@@ -0,0 +1,58 @@
using System;
using System.Security.Cryptography;
namespace StellaOps.Cryptography.Kms;
public sealed partial class FileKmsClient
{
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, 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, signature, HashAlgorithmName.SHA256);
}
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."),
};
}

View File

@@ -0,0 +1,105 @@
using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class FileKmsClient
{
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)
? $"{_timeProvider.GetUtcNow():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 ? _timeProvider.GetUtcNow() : 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();
}
}
}

View File

@@ -0,0 +1,25 @@
using System;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Cryptography.Kms;
public sealed partial class FileKmsClient
{
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) : TimeProvider.System.GetUtcNow());
return new KmsKeyMetadata(record.KeyId, record.Algorithm, record.State, createdAt, versions);
}
}

View File

@@ -0,0 +1,58 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class FileKmsClient
{
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();
}
}
}

View File

@@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Cryptography.Kms;
public sealed partial class FileKmsClient
{
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;
}
}

View File

@@ -0,0 +1,47 @@
using System;
using System.IO;
using System.Linq;
namespace StellaOps.Cryptography.Kms;
public sealed partial class FileKmsClient
{
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 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;
}
}

View File

@@ -0,0 +1,97 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class FileKmsClient
{
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 = _timeProvider.GetUtcNow(),
};
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 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);
}
}

View File

@@ -0,0 +1,99 @@
using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class FileKmsClient
{
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 = _timeProvider.GetUtcNow();
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 = _timeProvider.GetUtcNow();
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();
}
}
}

View File

@@ -0,0 +1,47 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class FileKmsClient
{
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();
}
}
}

View File

@@ -0,0 +1,44 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class FileKmsClient
{
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();
}
}
}

View File

@@ -1,17 +1,17 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
namespace StellaOps.Cryptography.Kms;
/// <summary>
/// File-backed KMS implementation that stores encrypted key material on disk.
/// </summary>
public sealed class FileKmsClient : IKmsClient, IDisposable
public sealed partial class FileKmsClient : IKmsClient, IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
Converters =
@@ -50,663 +50,10 @@ public sealed class FileKmsClient : IKmsClient, IDisposable
Directory.CreateDirectory(_options.RootPath);
}
public async Task<KmsSignResult> SignAsync(
string keyId,
string? keyVersion,
ReadOnlyMemory<byte> data,
CancellationToken cancellationToken = default)
public FileKmsClient(IOptions<FileKmsOptions> options, TimeProvider timeProvider)
: this(options?.Value ?? throw new ArgumentNullException(nameof(options)), timeProvider)
{
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> 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)
? $"{_timeProvider.GetUtcNow():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 ? _timeProvider.GetUtcNow() : 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);
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 = _timeProvider.GetUtcNow();
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 = _timeProvider.GetUtcNow();
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 = _timeProvider.GetUtcNow(),
};
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, 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, signature, 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();
using var aesGcm = new AesGcm(key, tag.Length);
try
{
aesGcm.Encrypt(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];
using var aesGcm = new AesGcm(key, tag.Length);
aesGcm.Decrypt(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) : TimeProvider.System.GetUtcNow());
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();
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;
}
}

View File

@@ -0,0 +1,39 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class GcpKmsClient
{
private async Task<CryptoKeySnapshot> GetCachedMetadataAsync(string keyId, CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
if (_metadataCache.TryGetValue(keyId, out var cached) && cached.ExpiresAt > now)
{
return cached.Snapshot;
}
var metadata = await _facade.GetCryptoKeyMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
var versions = await _facade.ListKeyVersionsAsync(keyId, cancellationToken).ConfigureAwait(false);
var snapshot = new CryptoKeySnapshot(metadata, versions);
_metadataCache[keyId] = new CachedCryptoKey(snapshot, now.Add(_metadataCacheDuration));
return snapshot;
}
private async Task<GcpPublicMaterial> GetCachedPublicKeyAsync(string versionName, CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
if (_publicKeyCache.TryGetValue(versionName, out var cached) && cached.ExpiresAt > now)
{
return cached.Material;
}
var material = await _facade.GetPublicKeyAsync(versionName, cancellationToken).ConfigureAwait(false);
var der = DecodePem(material.Pem);
var publicMaterial = new GcpPublicMaterial(material.VersionName, material.Algorithm, der);
_publicKeyCache[versionName] = new CachedPublicKey(publicMaterial, now.Add(_publicKeyCacheDuration));
return publicMaterial;
}
}

View File

@@ -0,0 +1,70 @@
using Microsoft.IdentityModel.Tokens;
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Cryptography.Kms;
public sealed partial class GcpKmsClient
{
private static KmsKeyState MapState(GcpCryptoKeyVersionState state)
=> state switch
{
GcpCryptoKeyVersionState.Enabled => KmsKeyState.Active,
GcpCryptoKeyVersionState.PendingGeneration or GcpCryptoKeyVersionState.PendingImport => KmsKeyState.PendingRotation,
_ => KmsKeyState.Revoked,
};
private static string ResolveCurve(string algorithm)
{
return algorithm switch
{
"EC_SIGN_P256_SHA256" => JsonWebKeyECTypes.P256,
"EC_SIGN_P384_SHA384" => JsonWebKeyECTypes.P384,
_ => JsonWebKeyECTypes.P256,
};
}
private static byte[] DecodePem(string pem)
{
if (string.IsNullOrWhiteSpace(pem))
{
throw new InvalidOperationException("Public key PEM cannot be empty.");
}
var builder = new StringBuilder(pem.Length);
using var reader = new StringReader(pem);
string? line;
while ((line = reader.ReadLine()) is not null)
{
if (line.StartsWith("-----", StringComparison.Ordinal))
{
continue;
}
builder.Append(line.Trim());
}
return Convert.FromBase64String(builder.ToString());
}
private static byte[] ComputeSha256(ReadOnlyMemory<byte> data)
{
var digest = new byte[32];
if (!SHA256.TryHashData(data.Span, digest, out _))
{
throw new InvalidOperationException("Failed to hash payload with SHA-256.");
}
return digest;
}
private void ThrowIfDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(GcpKmsClient));
}
}
}

View File

@@ -0,0 +1,71 @@
using System;
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class GcpKmsClient
{
public async Task<KmsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
var snapshot = await GetCachedMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
var versions = ImmutableArray.CreateBuilder<KmsKeyVersionMetadata>(snapshot.Versions.Count);
foreach (var version in snapshot.Versions)
{
var publicMaterial = await GetCachedPublicKeyAsync(version.VersionName, cancellationToken).ConfigureAwait(false);
versions.Add(new KmsKeyVersionMetadata(
version.VersionName,
MapState(version.State),
version.CreateTime,
version.DestroyTime,
Convert.ToBase64String(publicMaterial.SubjectPublicKeyInfo),
ResolveCurve(publicMaterial.Algorithm)));
}
var overallState = versions.Any(v => v.State == KmsKeyState.Active)
? KmsKeyState.Active
: versions.Any(v => v.State == KmsKeyState.PendingRotation)
? KmsKeyState.PendingRotation
: KmsKeyState.Revoked;
return new KmsKeyMetadata(
snapshot.Metadata.KeyName,
KmsAlgorithms.Es256,
overallState,
snapshot.Metadata.CreateTime,
versions.MoveToImmutable());
}
public async Task<KmsKeyMaterial> ExportAsync(
string keyId,
string? keyVersion,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
var snapshot = await GetCachedMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
var versionResource = await ResolveVersionAsync(keyId, keyVersion, cancellationToken).ConfigureAwait(false);
var publicMaterial = await GetCachedPublicKeyAsync(versionResource, cancellationToken).ConfigureAwait(false);
using var ecdsa = ECDsa.Create();
ecdsa.ImportSubjectPublicKeyInfo(publicMaterial.SubjectPublicKeyInfo, out _);
var parameters = ecdsa.ExportParameters(false);
return new KmsKeyMaterial(
snapshot.Metadata.KeyName,
versionResource,
KmsAlgorithms.Es256,
ResolveCurve(publicMaterial.Algorithm),
Array.Empty<byte>(),
parameters.Q.X ?? throw new InvalidOperationException("Public key missing X coordinate."),
parameters.Q.Y ?? throw new InvalidOperationException("Public key missing Y coordinate."),
snapshot.Metadata.CreateTime);
}
}

View File

@@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Cryptography.Kms;
public sealed partial class GcpKmsClient
{
private sealed record CachedCryptoKey(CryptoKeySnapshot Snapshot, DateTimeOffset ExpiresAt);
private sealed record CachedPublicKey(GcpPublicMaterial Material, DateTimeOffset ExpiresAt);
private sealed record CryptoKeySnapshot(GcpCryptoKeyMetadata Metadata, IReadOnlyList<GcpCryptoKeyVersionMetadata> Versions);
private sealed record GcpPublicMaterial(string VersionName, string Algorithm, byte[] SubjectPublicKeyInfo);
}

View File

@@ -0,0 +1,30 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class GcpKmsClient
{
private async Task<string> ResolveVersionAsync(string keyId, string? keyVersion, CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(keyVersion))
{
return keyVersion!;
}
var snapshot = await GetCachedMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(snapshot.Metadata.PrimaryVersionName))
{
return snapshot.Metadata.PrimaryVersionName!;
}
var firstActive = snapshot.Versions.FirstOrDefault(v => v.State == GcpCryptoKeyVersionState.Enabled);
if (firstActive is not null)
{
return firstActive.VersionName;
}
throw new InvalidOperationException($"Crypto key '{keyId}' does not have an active primary version.");
}
}

View File

@@ -0,0 +1,70 @@
using System;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class GcpKmsClient
{
public async Task<KmsSignResult> SignAsync(
string keyId,
string? keyVersion,
ReadOnlyMemory<byte> data,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
if (data.IsEmpty)
{
throw new ArgumentException("Signing payload cannot be empty.", nameof(data));
}
var digest = ComputeSha256(data);
try
{
var versionResource = await ResolveVersionAsync(keyId, keyVersion, cancellationToken).ConfigureAwait(false);
var result = await _facade.SignAsync(versionResource, digest, cancellationToken).ConfigureAwait(false);
return new KmsSignResult(
keyId,
string.IsNullOrWhiteSpace(result.VersionName) ? versionResource : result.VersionName,
KmsAlgorithms.Es256,
result.Signature);
}
finally
{
CryptographicOperations.ZeroMemory(digest.AsSpan());
}
}
public async Task<bool> VerifyAsync(
string keyId,
string? keyVersion,
ReadOnlyMemory<byte> data,
ReadOnlyMemory<byte> signature,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
if (data.IsEmpty || signature.IsEmpty)
{
return false;
}
var digest = ComputeSha256(data);
try
{
var versionResource = await ResolveVersionAsync(keyId, keyVersion, cancellationToken).ConfigureAwait(false);
var publicMaterial = await GetCachedPublicKeyAsync(versionResource, cancellationToken).ConfigureAwait(false);
using var ecdsa = ECDsa.Create();
ecdsa.ImportSubjectPublicKeyInfo(publicMaterial.SubjectPublicKeyInfo, out _);
return ecdsa.VerifyHash(digest, signature.ToArray());
}
finally
{
CryptographicOperations.ZeroMemory(digest.AsSpan());
}
}
}

View File

@@ -1,17 +1,14 @@
using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.Options;
using System;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
/// <summary>
/// Google Cloud KMS implementation of <see cref="IKmsClient"/>.
/// </summary>
public sealed class GcpKmsClient : IKmsClient, IDisposable
public sealed partial class GcpKmsClient : IKmsClient, IDisposable
{
private readonly IGcpKmsFacade _facade;
private readonly TimeProvider _timeProvider;
@@ -32,126 +29,9 @@ public sealed class GcpKmsClient : IKmsClient, IDisposable
_publicKeyCacheDuration = options.PublicKeyCacheDuration;
}
public async Task<KmsSignResult> SignAsync(
string keyId,
string? keyVersion,
ReadOnlyMemory<byte> data,
CancellationToken cancellationToken = default)
public GcpKmsClient(IGcpKmsFacade facade, IOptions<GcpKmsOptions> options, TimeProvider timeProvider)
: this(facade, options?.Value ?? new GcpKmsOptions(), timeProvider)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
if (data.IsEmpty)
{
throw new ArgumentException("Signing payload cannot be empty.", nameof(data));
}
var digest = ComputeSha256(data);
try
{
var versionResource = await ResolveVersionAsync(keyId, keyVersion, cancellationToken).ConfigureAwait(false);
var result = await _facade.SignAsync(versionResource, digest, cancellationToken).ConfigureAwait(false);
return new KmsSignResult(
keyId,
string.IsNullOrWhiteSpace(result.VersionName) ? versionResource : result.VersionName,
KmsAlgorithms.Es256,
result.Signature);
}
finally
{
CryptographicOperations.ZeroMemory(digest.AsSpan());
}
}
public async Task<bool> VerifyAsync(
string keyId,
string? keyVersion,
ReadOnlyMemory<byte> data,
ReadOnlyMemory<byte> signature,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
if (data.IsEmpty || signature.IsEmpty)
{
return false;
}
var digest = ComputeSha256(data);
try
{
var versionResource = await ResolveVersionAsync(keyId, keyVersion, cancellationToken).ConfigureAwait(false);
var publicMaterial = await GetCachedPublicKeyAsync(versionResource, cancellationToken).ConfigureAwait(false);
using var ecdsa = ECDsa.Create();
ecdsa.ImportSubjectPublicKeyInfo(publicMaterial.SubjectPublicKeyInfo, out _);
return ecdsa.VerifyHash(digest, signature.ToArray());
}
finally
{
CryptographicOperations.ZeroMemory(digest.AsSpan());
}
}
public async Task<KmsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
var snapshot = await GetCachedMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
var versions = ImmutableArray.CreateBuilder<KmsKeyVersionMetadata>(snapshot.Versions.Count);
foreach (var version in snapshot.Versions)
{
var publicMaterial = await GetCachedPublicKeyAsync(version.VersionName, cancellationToken).ConfigureAwait(false);
versions.Add(new KmsKeyVersionMetadata(
version.VersionName,
MapState(version.State),
version.CreateTime,
version.DestroyTime,
Convert.ToBase64String(publicMaterial.SubjectPublicKeyInfo),
ResolveCurve(publicMaterial.Algorithm)));
}
var overallState = versions.Any(v => v.State == KmsKeyState.Active)
? KmsKeyState.Active
: versions.Any(v => v.State == KmsKeyState.PendingRotation)
? KmsKeyState.PendingRotation
: KmsKeyState.Revoked;
return new KmsKeyMetadata(
snapshot.Metadata.KeyName,
KmsAlgorithms.Es256,
overallState,
snapshot.Metadata.CreateTime,
versions.MoveToImmutable());
}
public async Task<KmsKeyMaterial> ExportAsync(
string keyId,
string? keyVersion,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
var snapshot = await GetCachedMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
var versionResource = await ResolveVersionAsync(keyId, keyVersion, cancellationToken).ConfigureAwait(false);
var publicMaterial = await GetCachedPublicKeyAsync(versionResource, cancellationToken).ConfigureAwait(false);
using var ecdsa = ECDsa.Create();
ecdsa.ImportSubjectPublicKeyInfo(publicMaterial.SubjectPublicKeyInfo, out _);
var parameters = ecdsa.ExportParameters(false);
return new KmsKeyMaterial(
snapshot.Metadata.KeyName,
versionResource,
KmsAlgorithms.Es256,
ResolveCurve(publicMaterial.Algorithm),
Array.Empty<byte>(),
parameters.Q.X ?? throw new InvalidOperationException("Public key missing X coordinate."),
parameters.Q.Y ?? throw new InvalidOperationException("Public key missing Y coordinate."),
snapshot.Metadata.CreateTime);
}
public Task<KmsKeyMetadata> RotateAsync(string keyId, CancellationToken cancellationToken = default)
@@ -170,125 +50,4 @@ public sealed class GcpKmsClient : IKmsClient, IDisposable
_disposed = true;
_facade.Dispose();
}
private async Task<CryptoKeySnapshot> GetCachedMetadataAsync(string keyId, CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
if (_metadataCache.TryGetValue(keyId, out var cached) && cached.ExpiresAt > now)
{
return cached.Snapshot;
}
var metadata = await _facade.GetCryptoKeyMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
var versions = await _facade.ListKeyVersionsAsync(keyId, cancellationToken).ConfigureAwait(false);
var snapshot = new CryptoKeySnapshot(metadata, versions);
_metadataCache[keyId] = new CachedCryptoKey(snapshot, now.Add(_metadataCacheDuration));
return snapshot;
}
private async Task<GcpPublicMaterial> GetCachedPublicKeyAsync(string versionName, CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
if (_publicKeyCache.TryGetValue(versionName, out var cached) && cached.ExpiresAt > now)
{
return cached.Material;
}
var material = await _facade.GetPublicKeyAsync(versionName, cancellationToken).ConfigureAwait(false);
var der = DecodePem(material.Pem);
var publicMaterial = new GcpPublicMaterial(material.VersionName, material.Algorithm, der);
_publicKeyCache[versionName] = new CachedPublicKey(publicMaterial, now.Add(_publicKeyCacheDuration));
return publicMaterial;
}
private async Task<string> ResolveVersionAsync(string keyId, string? keyVersion, CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(keyVersion))
{
return keyVersion!;
}
var snapshot = await GetCachedMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(snapshot.Metadata.PrimaryVersionName))
{
return snapshot.Metadata.PrimaryVersionName!;
}
var firstActive = snapshot.Versions.FirstOrDefault(v => v.State == GcpCryptoKeyVersionState.Enabled);
if (firstActive is not null)
{
return firstActive.VersionName;
}
throw new InvalidOperationException($"Crypto key '{keyId}' does not have an active primary version.");
}
private static KmsKeyState MapState(GcpCryptoKeyVersionState state)
=> state switch
{
GcpCryptoKeyVersionState.Enabled => KmsKeyState.Active,
GcpCryptoKeyVersionState.PendingGeneration or GcpCryptoKeyVersionState.PendingImport => KmsKeyState.PendingRotation,
_ => KmsKeyState.Revoked,
};
private static string ResolveCurve(string algorithm)
{
return algorithm switch
{
"EC_SIGN_P256_SHA256" => JsonWebKeyECTypes.P256,
"EC_SIGN_P384_SHA384" => JsonWebKeyECTypes.P384,
_ => JsonWebKeyECTypes.P256,
};
}
private static byte[] DecodePem(string pem)
{
if (string.IsNullOrWhiteSpace(pem))
{
throw new InvalidOperationException("Public key PEM cannot be empty.");
}
var builder = new StringBuilder(pem.Length);
using var reader = new StringReader(pem);
string? line;
while ((line = reader.ReadLine()) is not null)
{
if (line.StartsWith("-----", StringComparison.Ordinal))
{
continue;
}
builder.Append(line.Trim());
}
return Convert.FromBase64String(builder.ToString());
}
private static byte[] ComputeSha256(ReadOnlyMemory<byte> data)
{
var digest = new byte[32];
if (!SHA256.TryHashData(data.Span, digest, out _))
{
throw new InvalidOperationException("Failed to hash payload with SHA-256.");
}
return digest;
}
private void ThrowIfDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(GcpKmsClient));
}
}
private sealed record CachedCryptoKey(CryptoKeySnapshot Snapshot, DateTimeOffset ExpiresAt);
private sealed record CachedPublicKey(GcpPublicMaterial Material, DateTimeOffset ExpiresAt);
private sealed record CryptoKeySnapshot(GcpCryptoKeyMetadata Metadata, IReadOnlyList<GcpCryptoKeyVersionMetadata> Versions);
private sealed record GcpPublicMaterial(string VersionName, string Algorithm, byte[] SubjectPublicKeyInfo);
}
}

View File

@@ -0,0 +1,17 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public interface IGcpKmsFacade : IDisposable
{
Task<GcpSignResult> SignAsync(string versionName, ReadOnlyMemory<byte> digest, CancellationToken cancellationToken);
Task<GcpCryptoKeyMetadata> GetCryptoKeyMetadataAsync(string keyName, CancellationToken cancellationToken);
Task<IReadOnlyList<GcpCryptoKeyVersionMetadata>> ListKeyVersionsAsync(string keyName, CancellationToken cancellationToken);
Task<GcpPublicKeyMaterial> GetPublicKeyAsync(string versionName, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,14 @@
using System;
namespace StellaOps.Cryptography.Kms;
internal sealed partial class GcpKmsFacade
{
public void Dispose()
{
if (_ownsClient && _client is IDisposable disposable)
{
disposable.Dispose();
}
}
}

View File

@@ -0,0 +1,37 @@
using Google.Cloud.Kms.V1;
using Google.Protobuf.WellKnownTypes;
using System;
namespace StellaOps.Cryptography.Kms;
internal sealed partial class GcpKmsFacade
{
private static GcpCryptoKeyVersionState MapState(CryptoKeyVersion.Types.CryptoKeyVersionState state)
=> state switch
{
CryptoKeyVersion.Types.CryptoKeyVersionState.Enabled => GcpCryptoKeyVersionState.Enabled,
CryptoKeyVersion.Types.CryptoKeyVersionState.Disabled => GcpCryptoKeyVersionState.Disabled,
CryptoKeyVersion.Types.CryptoKeyVersionState.DestroyScheduled => GcpCryptoKeyVersionState.DestroyScheduled,
CryptoKeyVersion.Types.CryptoKeyVersionState.Destroyed => GcpCryptoKeyVersionState.Destroyed,
CryptoKeyVersion.Types.CryptoKeyVersionState.PendingGeneration => GcpCryptoKeyVersionState.PendingGeneration,
CryptoKeyVersion.Types.CryptoKeyVersionState.PendingImport => GcpCryptoKeyVersionState.PendingImport,
CryptoKeyVersion.Types.CryptoKeyVersionState.ImportFailed => GcpCryptoKeyVersionState.ImportFailed,
CryptoKeyVersion.Types.CryptoKeyVersionState.GenerationFailed => GcpCryptoKeyVersionState.GenerationFailed,
_ => GcpCryptoKeyVersionState.Unspecified,
};
private DateTimeOffset ToDateTimeOffsetOrUtcNow(Timestamp? timestamp)
{
if (timestamp is null)
{
return _timeProvider.GetUtcNow();
}
if (timestamp.Seconds == 0 && timestamp.Nanos == 0)
{
return _timeProvider.GetUtcNow();
}
return timestamp.ToDateTimeOffset();
}
}

View File

@@ -0,0 +1,64 @@
using Google.Cloud.Kms.V1;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
internal sealed partial class GcpKmsFacade
{
public async Task<GcpCryptoKeyMetadata> GetCryptoKeyMetadataAsync(string keyName, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyName);
var response = await _client.GetCryptoKeyAsync(new GetCryptoKeyRequest
{
Name = keyName,
}, cancellationToken).ConfigureAwait(false);
return new GcpCryptoKeyMetadata(
response.Name,
response.Primary?.Name,
ToDateTimeOffsetOrUtcNow(response.CreateTime));
}
public async Task<IReadOnlyList<GcpCryptoKeyVersionMetadata>> ListKeyVersionsAsync(string keyName, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyName);
var results = new List<GcpCryptoKeyVersionMetadata>();
var request = new ListCryptoKeyVersionsRequest
{
Parent = keyName,
};
await foreach (var version in _client.ListCryptoKeyVersionsAsync(request)
.WithCancellation(cancellationToken)
.ConfigureAwait(false))
{
results.Add(new GcpCryptoKeyVersionMetadata(
version.Name,
MapState(version.State),
ToDateTimeOffsetOrUtcNow(version.CreateTime),
version.DestroyTime is null ? null : ToDateTimeOffsetOrUtcNow(version.DestroyTime)));
}
return results;
}
public async Task<GcpPublicKeyMaterial> GetPublicKeyAsync(string versionName, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(versionName);
var response = await _client.GetPublicKeyAsync(new GetPublicKeyRequest
{
Name = versionName,
}, cancellationToken).ConfigureAwait(false);
return new GcpPublicKeyMaterial(
response.Name ?? versionName,
response.Algorithm.ToString(),
response.Pem);
}
}

View File

@@ -0,0 +1,28 @@
using System;
namespace StellaOps.Cryptography.Kms;
public sealed record GcpSignResult(string VersionName, byte[] Signature);
public sealed record GcpCryptoKeyMetadata(string KeyName, string? PrimaryVersionName, DateTimeOffset CreateTime);
public enum GcpCryptoKeyVersionState
{
Unspecified = 0,
PendingGeneration = 1,
Enabled = 2,
Disabled = 3,
DestroyScheduled = 4,
Destroyed = 5,
PendingImport = 6,
ImportFailed = 7,
GenerationFailed = 8,
}
public sealed record GcpCryptoKeyVersionMetadata(
string VersionName,
GcpCryptoKeyVersionState State,
DateTimeOffset CreateTime,
DateTimeOffset? DestroyTime);
public sealed record GcpPublicKeyMaterial(string VersionName, string Algorithm, string Pem);

View File

@@ -0,0 +1,26 @@
using Google.Cloud.Kms.V1;
using Google.Protobuf;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
internal sealed partial class GcpKmsFacade
{
public async Task<GcpSignResult> SignAsync(string versionName, ReadOnlyMemory<byte> digest, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(versionName);
var response = await _client.AsymmetricSignAsync(new AsymmetricSignRequest
{
Name = versionName,
Digest = new Digest
{
Sha256 = ByteString.CopyFrom(digest.ToArray()),
},
}, cancellationToken).ConfigureAwait(false);
return new GcpSignResult(response.Name ?? versionName, response.Signature.ToByteArray());
}
}

View File

@@ -1,46 +1,10 @@
using Google.Cloud.Kms.V1;
using Google.Protobuf;
using Google.Protobuf.WellKnownTypes;
using Microsoft.Extensions.Options;
using System;
namespace StellaOps.Cryptography.Kms;
public interface IGcpKmsFacade : IDisposable
{
Task<GcpSignResult> SignAsync(string versionName, ReadOnlyMemory<byte> digest, CancellationToken cancellationToken);
Task<GcpCryptoKeyMetadata> GetCryptoKeyMetadataAsync(string keyName, CancellationToken cancellationToken);
Task<IReadOnlyList<GcpCryptoKeyVersionMetadata>> ListKeyVersionsAsync(string keyName, CancellationToken cancellationToken);
Task<GcpPublicKeyMaterial> GetPublicKeyAsync(string versionName, CancellationToken cancellationToken);
}
public sealed record GcpSignResult(string VersionName, byte[] Signature);
public sealed record GcpCryptoKeyMetadata(string KeyName, string? PrimaryVersionName, DateTimeOffset CreateTime);
public enum GcpCryptoKeyVersionState
{
Unspecified = 0,
PendingGeneration = 1,
Enabled = 2,
Disabled = 3,
DestroyScheduled = 4,
Destroyed = 5,
PendingImport = 6,
ImportFailed = 7,
GenerationFailed = 8,
}
public sealed record GcpCryptoKeyVersionMetadata(
string VersionName,
GcpCryptoKeyVersionState State,
DateTimeOffset CreateTime,
DateTimeOffset? DestroyTime);
public sealed record GcpPublicKeyMaterial(string VersionName, string Algorithm, string Pem);
internal sealed class GcpKmsFacade : IGcpKmsFacade
internal sealed partial class GcpKmsFacade : IGcpKmsFacade
{
private readonly KeyManagementServiceClient _client;
private readonly bool _ownsClient;
@@ -61,115 +25,15 @@ internal sealed class GcpKmsFacade : IGcpKmsFacade
_ownsClient = true;
}
public GcpKmsFacade(IOptions<GcpKmsOptions> options, TimeProvider timeProvider)
: this(options?.Value ?? new GcpKmsOptions(), timeProvider)
{
}
public GcpKmsFacade(KeyManagementServiceClient client, TimeProvider? timeProvider = null)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_timeProvider = timeProvider ?? TimeProvider.System;
_ownsClient = false;
}
public async Task<GcpSignResult> SignAsync(string versionName, ReadOnlyMemory<byte> digest, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(versionName);
var response = await _client.AsymmetricSignAsync(new AsymmetricSignRequest
{
Name = versionName,
Digest = new Digest
{
Sha256 = ByteString.CopyFrom(digest.ToArray()),
},
}, cancellationToken).ConfigureAwait(false);
return new GcpSignResult(response.Name ?? versionName, response.Signature.ToByteArray());
}
public async Task<GcpCryptoKeyMetadata> GetCryptoKeyMetadataAsync(string keyName, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyName);
var response = await _client.GetCryptoKeyAsync(new GetCryptoKeyRequest
{
Name = keyName,
}, cancellationToken).ConfigureAwait(false);
return new GcpCryptoKeyMetadata(
response.Name,
response.Primary?.Name,
ToDateTimeOffsetOrUtcNow(response.CreateTime));
}
public async Task<IReadOnlyList<GcpCryptoKeyVersionMetadata>> ListKeyVersionsAsync(string keyName, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyName);
var results = new List<GcpCryptoKeyVersionMetadata>();
var request = new ListCryptoKeyVersionsRequest
{
Parent = keyName,
};
await foreach (var version in _client.ListCryptoKeyVersionsAsync(request).WithCancellation(cancellationToken).ConfigureAwait(false))
{
results.Add(new GcpCryptoKeyVersionMetadata(
version.Name,
MapState(version.State),
ToDateTimeOffsetOrUtcNow(version.CreateTime),
version.DestroyTime is null ? null : ToDateTimeOffsetOrUtcNow(version.DestroyTime)));
}
return results;
}
public async Task<GcpPublicKeyMaterial> GetPublicKeyAsync(string versionName, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(versionName);
var response = await _client.GetPublicKeyAsync(new GetPublicKeyRequest
{
Name = versionName,
}, cancellationToken).ConfigureAwait(false);
return new GcpPublicKeyMaterial(
response.Name ?? versionName,
response.Algorithm.ToString(),
response.Pem);
}
private static GcpCryptoKeyVersionState MapState(CryptoKeyVersion.Types.CryptoKeyVersionState state)
=> state switch
{
CryptoKeyVersion.Types.CryptoKeyVersionState.Enabled => GcpCryptoKeyVersionState.Enabled,
CryptoKeyVersion.Types.CryptoKeyVersionState.Disabled => GcpCryptoKeyVersionState.Disabled,
CryptoKeyVersion.Types.CryptoKeyVersionState.DestroyScheduled => GcpCryptoKeyVersionState.DestroyScheduled,
CryptoKeyVersion.Types.CryptoKeyVersionState.Destroyed => GcpCryptoKeyVersionState.Destroyed,
CryptoKeyVersion.Types.CryptoKeyVersionState.PendingGeneration => GcpCryptoKeyVersionState.PendingGeneration,
CryptoKeyVersion.Types.CryptoKeyVersionState.PendingImport => GcpCryptoKeyVersionState.PendingImport,
CryptoKeyVersion.Types.CryptoKeyVersionState.ImportFailed => GcpCryptoKeyVersionState.ImportFailed,
CryptoKeyVersion.Types.CryptoKeyVersionState.GenerationFailed => GcpCryptoKeyVersionState.GenerationFailed,
_ => GcpCryptoKeyVersionState.Unspecified,
};
public void Dispose()
{
if (_ownsClient && _client is IDisposable disposable)
{
disposable.Dispose();
}
}
private DateTimeOffset ToDateTimeOffsetOrUtcNow(Timestamp? timestamp)
{
if (timestamp is null)
{
return _timeProvider.GetUtcNow();
}
if (timestamp.Seconds == 0 && timestamp.Nanos == 0)
{
return _timeProvider.GetUtcNow();
}
return timestamp.ToDateTimeOffset();
}
}
}

View File

@@ -5,8 +5,8 @@ namespace StellaOps.Cryptography.Kms;
/// </summary>
public sealed class GcpKmsOptions
{
private TimeSpan metadataCacheDuration = TimeSpan.FromMinutes(5);
private TimeSpan publicKeyCacheDuration = TimeSpan.FromMinutes(10);
private TimeSpan _metadataCacheDuration = TimeSpan.FromMinutes(5);
private TimeSpan _publicKeyCacheDuration = TimeSpan.FromMinutes(10);
/// <summary>
/// Gets or sets the service endpoint (default: <c>kms.googleapis.com</c>).
@@ -18,8 +18,8 @@ public sealed class GcpKmsOptions
/// </summary>
public TimeSpan MetadataCacheDuration
{
get => metadataCacheDuration;
set => metadataCacheDuration = EnsurePositive(value, TimeSpan.FromMinutes(5));
get => _metadataCacheDuration;
set => _metadataCacheDuration = EnsurePositive(value, TimeSpan.FromMinutes(5));
}
/// <summary>
@@ -27,16 +27,10 @@ public sealed class GcpKmsOptions
/// </summary>
public TimeSpan PublicKeyCacheDuration
{
get => publicKeyCacheDuration;
set => publicKeyCacheDuration = EnsurePositive(value, TimeSpan.FromMinutes(10));
get => _publicKeyCacheDuration;
set => _publicKeyCacheDuration = EnsurePositive(value, TimeSpan.FromMinutes(10));
}
/// <summary>
/// Gets or sets an optional factory that can construct a custom GCP facade (primarily used for testing).
/// </summary>
public Func<IServiceProvider, IGcpKmsFacade>? FacadeFactory { get; set; }
private static TimeSpan EnsurePositive(TimeSpan value, TimeSpan @default)
=> value <= TimeSpan.Zero ? @default : value;
}

View File

@@ -2,3 +2,4 @@ using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Cryptography.Kms.Tests")]
namespace StellaOps.Cryptography.Kms;

View File

@@ -0,0 +1,41 @@
using Microsoft.IdentityModel.Tokens;
using System.Security.Cryptography;
namespace StellaOps.Cryptography.Kms;
public sealed partial class KmsCryptoProvider
{
private static string ResolveCurveName(ECCurve curve)
{
if (!string.IsNullOrWhiteSpace(curve.Oid.FriendlyName))
{
return curve.Oid.FriendlyName switch
{
"nistP256" => JsonWebKeyECTypes.P256,
"nistP384" => JsonWebKeyECTypes.P384,
"nistP521" => JsonWebKeyECTypes.P521,
_ => JsonWebKeyECTypes.P256
};
}
return JsonWebKeyECTypes.P256;
}
private static string ResolveCurveName(int coordinateLength)
=> coordinateLength switch
{
32 => JsonWebKeyECTypes.P256,
48 => JsonWebKeyECTypes.P384,
66 => JsonWebKeyECTypes.P521,
_ => JsonWebKeyECTypes.P256
};
private static ECCurve ResolveCurve(string curve)
=> curve switch
{
JsonWebKeyECTypes.P256 or "P-256" => ECCurve.NamedCurves.nistP256,
JsonWebKeyECTypes.P384 or "P-384" => ECCurve.NamedCurves.nistP384,
JsonWebKeyECTypes.P521 or "P-521" => ECCurve.NamedCurves.nistP521,
_ => ECCurve.NamedCurves.nistP256
};
}

View File

@@ -0,0 +1,93 @@
using System;
using System.Security.Cryptography;
using StellaOps.Cryptography;
namespace StellaOps.Cryptography.Kms;
public sealed partial class KmsCryptoProvider
{
internal static class KmsMetadataKeys
{
public const string Version = "kms.version";
}
private static bool TryCreateSigningKey(KmsSigningRegistration registration, out CryptoSigningKey signingKey)
{
signingKey = default!;
if (registration.PublicKey is null)
{
return false;
}
var curve = ResolveCurve(registration.PublicKey.Curve);
var parameters = new ECParameters
{
Curve = curve,
Q = new ECPoint
{
X = registration.PublicKey.Qx,
Y = registration.PublicKey.Qy
}
};
signingKey = new CryptoSigningKey(
registration.Reference,
registration.Algorithm,
in parameters,
verificationOnly: true,
registration.CreatedAt,
metadata: registration.Metadata);
return true;
}
private static bool TryResolvePublicKey(CryptoSigningKey signingKey, out KmsPublicKey publicKey)
{
if (TryCreatePublicKey(signingKey.PublicParameters, out publicKey))
{
return true;
}
if (!signingKey.PublicKey.IsEmpty && TryCreatePublicKey(signingKey.PublicKey, out publicKey))
{
return true;
}
publicKey = default!;
return false;
}
private static bool TryCreatePublicKey(ECParameters parameters, out KmsPublicKey publicKey)
{
if (parameters.Q.X is null || parameters.Q.Y is null)
{
publicKey = default!;
return false;
}
var curve = ResolveCurveName(parameters.Curve);
publicKey = new KmsPublicKey(curve, (byte[])parameters.Q.X.Clone(), (byte[])parameters.Q.Y.Clone());
return true;
}
private static bool TryCreatePublicKey(ReadOnlyMemory<byte> rawKey, out KmsPublicKey publicKey)
{
if (rawKey.IsEmpty)
{
publicKey = default!;
return false;
}
if (rawKey.Length % 2 != 0)
{
publicKey = default!;
return false;
}
var coordinateLength = rawKey.Length / 2;
var qx = rawKey.Slice(0, coordinateLength).ToArray();
var qy = rawKey.Slice(coordinateLength, coordinateLength).ToArray();
var curve = ResolveCurveName(coordinateLength);
publicKey = new KmsPublicKey(curve, qx, qy);
return true;
}
}

View File

@@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
using StellaOps.Cryptography;
namespace StellaOps.Cryptography.Kms;
internal sealed record KmsSigningRegistration(
CryptoKeyReference Reference,
string VersionId,
string Algorithm,
DateTimeOffset CreatedAt,
IReadOnlyDictionary<string, string?> Metadata,
KmsPublicKey? PublicKey);
internal sealed record KmsPublicKey(string Curve, byte[] Qx, byte[] Qy);

View File

@@ -1,15 +1,14 @@
using Microsoft.IdentityModel.Tokens;
using StellaOps.Cryptography;
using System;
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Collections.Generic;
using StellaOps.Cryptography;
namespace StellaOps.Cryptography.Kms;
/// <summary>
/// Crypto provider that delegates signing operations to a KMS backend.
/// </summary>
public sealed class KmsCryptoProvider : ICryptoProvider
public sealed partial class KmsCryptoProvider : ICryptoProvider
{
private readonly IKmsClient _kmsClient;
private readonly ConcurrentDictionary<string, KmsSigningRegistration> _registrations = new(StringComparer.OrdinalIgnoreCase);
@@ -68,7 +67,20 @@ public sealed class KmsCryptoProvider : ICryptoProvider
throw new InvalidOperationException("KMS signing keys must include metadata entry 'kms.version'.");
}
var registration = new KmsSigningRegistration(signingKey.Reference.KeyId, versionId!, signingKey.AlgorithmId);
KmsPublicKey? publicKey = null;
if (TryResolvePublicKey(signingKey, out var resolved))
{
publicKey = resolved;
}
var registration = new KmsSigningRegistration(
signingKey.Reference,
versionId!,
signingKey.AlgorithmId,
signingKey.CreatedAt,
signingKey.Metadata,
publicKey);
_registrations.AddOrUpdate(signingKey.Reference.KeyId, registration, (_, _) => registration);
}
@@ -85,53 +97,17 @@ public sealed class KmsCryptoProvider : ICryptoProvider
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 metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
[KmsMetadataKeys.Version] = material.VersionId
};
var reference = new CryptoKeyReference(material.KeyId, Name);
CryptoSigningKey signingKey;
if (material.D.Length == 0)
if (!TryCreateSigningKey(registration, out var signingKey))
{
continue;
}
else
{
var parameters = new ECParameters
{
Curve = ECCurve.NamedCurves.nistP256,
D = material.D,
Q = new ECPoint
{
X = material.Qx,
Y = material.Qy,
},
};
signingKey = new CryptoSigningKey(
reference,
material.Algorithm,
in parameters,
material.CreatedAt,
metadata: metadata);
}
list.Add(signingKey);
}
return list;
}
internal static class KmsMetadataKeys
{
public const string Version = "kms.version";
}
}
internal sealed record KmsSigningRegistration(string KeyId, string VersionId, string Algorithm);

View File

@@ -1,7 +1,6 @@
using Microsoft.IdentityModel.Tokens;
using StellaOps.Cryptography;
using System.Security.Cryptography;
using System;
using System.Threading;
using System.Threading.Tasks;
@@ -10,47 +9,53 @@ 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;
private readonly KmsSigningRegistration _registration;
public KmsSigner(IKmsClient client, KmsSigningRegistration registration)
{
_client = client;
_keyId = registration.KeyId;
_versionId = registration.VersionId;
_algorithm = registration.Algorithm;
_client = client ?? throw new ArgumentNullException(nameof(client));
_registration = registration ?? throw new ArgumentNullException(nameof(registration));
}
public string KeyId => _keyId;
public string KeyId => _registration.Reference.KeyId;
public string AlgorithmId => _algorithm;
public string AlgorithmId => _registration.Algorithm;
public async ValueTask<byte[]> SignAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default)
{
var result = await _client.SignAsync(_keyId, _versionId, data, cancellationToken).ConfigureAwait(false);
var result = await _client.SignAsync(
_registration.Reference.KeyId,
_registration.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));
=> new(_client.VerifyAsync(
_registration.Reference.KeyId,
_registration.VersionId,
data,
signature,
cancellationToken));
public JsonWebKey ExportPublicJsonWebKey()
{
var material = _client.ExportAsync(_keyId, _versionId).GetAwaiter().GetResult();
var publicKey = _registration.PublicKey
?? throw new InvalidOperationException("KMS signing key is missing public key material.");
var jwk = new JsonWebKey
{
Kid = material.KeyId,
Alg = material.Algorithm,
Kid = _registration.Reference.KeyId,
Alg = _registration.Algorithm,
Kty = JsonWebAlgorithmsKeyTypes.EllipticCurve,
Use = JsonWebKeyUseNames.Sig,
Crv = JsonWebKeyECTypes.P256,
Crv = publicKey.Curve,
};
jwk.KeyOps.Add("sign");
jwk.KeyOps.Add("verify");
jwk.X = Base64UrlEncoder.Encode(material.Qx);
jwk.Y = Base64UrlEncoder.Encode(material.Qy);
jwk.X = Base64UrlEncoder.Encode(publicKey.Qx);
jwk.Y = Base64UrlEncoder.Encode(publicKey.Qy);
return jwk;
}
}
}

View File

@@ -0,0 +1,11 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
internal sealed class MissingFido2Authenticator : IFido2Authenticator
{
public Task<byte[]> SignAsync(string credentialId, ReadOnlyMemory<byte> digest, CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("IFido2Authenticator must be registered to use FIDO2 KMS.");
}

View File

@@ -0,0 +1,14 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public interface IPkcs11Facade : IDisposable
{
Task<Pkcs11KeyDescriptor> GetKeyAsync(CancellationToken cancellationToken);
Task<Pkcs11PublicKeyMaterial> GetPublicKeyAsync(CancellationToken cancellationToken);
Task<byte[]> SignDigestAsync(ReadOnlyMemory<byte> digest, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,106 @@
using Microsoft.IdentityModel.Tokens;
using Net.Pkcs11Interop.Common;
using Net.Pkcs11Interop.HighLevelAPI;
using System;
using System.Collections.Generic;
using System.Formats.Asn1;
using System.Linq;
namespace StellaOps.Cryptography.Kms;
internal sealed partial class Pkcs11InteropFacade
{
private IObjectHandle? FindKey(ISession session, CKO objectClass, string? label)
{
var template = new List<IObjectAttribute>
{
_factories.ObjectAttributeFactory.Create(CKA.CKA_CLASS, (uint)objectClass)
};
if (!string.IsNullOrWhiteSpace(label))
{
template.Add(_factories.ObjectAttributeFactory.Create(CKA.CKA_LABEL, label));
}
var handles = session.FindAllObjects(template);
return handles.FirstOrDefault();
}
private IObjectAttribute? GetAttribute(ISession session, IObjectHandle handle, CKA type)
{
var cacheKey = $"{handle.ObjectId}:{(uint)type}";
if (_attributeCache.TryGetValue(cacheKey, out var cached))
{
return cached.FirstOrDefault();
}
var attributes = session.GetAttributeValue(handle, new List<CKA> { type })
?.ToArray() ?? Array.Empty<IObjectAttribute>();
if (attributes.Length > 0)
{
_attributeCache[cacheKey] = attributes;
return attributes[0];
}
return null;
}
private static ISlot? ResolveSlot(IPkcs11Library pkcs11, Pkcs11Options options)
{
var slots = pkcs11.GetSlotList(SlotsType.WithTokenPresent);
if (slots.Count == 0)
{
return null;
}
if (!string.IsNullOrWhiteSpace(options.SlotId))
{
return slots.FirstOrDefault(slot => string.Equals(slot.SlotId.ToString(), options.SlotId, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(options.TokenLabel))
{
return slots.FirstOrDefault(slot =>
{
var info = slot.GetTokenInfo();
return string.Equals(info.Label?.Trim(), options.TokenLabel.Trim(), StringComparison.Ordinal);
});
}
return slots[0];
}
private static byte[] ExtractEcPoint(byte[] derEncoded)
{
var reader = new AsnReader(derEncoded, AsnEncodingRules.DER);
var point = reader.ReadOctetString();
reader.ThrowIfNotEmpty();
return point;
}
private static (string CurveName, int CoordinateSize) DecodeCurve(byte[] ecParamsDer)
{
var reader = new AsnReader(ecParamsDer, AsnEncodingRules.DER);
var oid = reader.ReadObjectIdentifier();
reader.ThrowIfNotEmpty();
var curve = oid switch
{
"1.2.840.10045.3.1.7" => JsonWebKeyECTypes.P256,
"1.3.132.0.34" => JsonWebKeyECTypes.P384,
"1.3.132.0.35" => JsonWebKeyECTypes.P521,
_ => throw new InvalidOperationException($"Unsupported EC curve OID '{oid}'."),
};
var coordinateSize = curve switch
{
JsonWebKeyECTypes.P256 => 32,
JsonWebKeyECTypes.P384 => 48,
JsonWebKeyECTypes.P521 => 66,
_ => throw new InvalidOperationException($"Unsupported EC curve '{curve}'."),
};
return (curve, coordinateSize);
}
}

View File

@@ -0,0 +1,14 @@
using System;
namespace StellaOps.Cryptography.Kms;
public sealed record Pkcs11KeyDescriptor(
string KeyId,
string? Label,
DateTimeOffset CreatedAt);
public sealed record Pkcs11PublicKeyMaterial(
string KeyId,
string Curve,
byte[] Qx,
byte[] Qy);

View File

@@ -0,0 +1,77 @@
using Net.Pkcs11Interop.Common;
using Net.Pkcs11Interop.HighLevelAPI;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
internal sealed partial class Pkcs11InteropFacade
{
private async Task<SessionContext> OpenSessionAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var session = _slot.OpenSession(SessionType.ReadOnly);
var loggedIn = false;
try
{
if (!string.IsNullOrWhiteSpace(_options.UserPin))
{
session.Login(CKU.CKU_USER, _options.UserPin);
loggedIn = true;
}
return new SessionContext(session, loggedIn);
}
catch
{
if (loggedIn)
{
try { session.Logout(); } catch { }
}
session.Dispose();
throw;
}
}
private sealed class SessionContext : IDisposable
{
private readonly ISession _session;
private readonly bool _logoutOnDispose;
private bool _disposed;
public SessionContext(ISession session, bool logoutOnDispose)
{
_session = session ?? throw new ArgumentNullException(nameof(session));
_logoutOnDispose = logoutOnDispose;
}
public ISession Session => _session;
public void Dispose()
{
if (_disposed)
{
return;
}
if (_logoutOnDispose)
{
try
{
_session.Logout();
}
catch
{
// ignore logout failures
}
}
_session.Dispose();
_disposed = true;
}
}
}

View File

@@ -1,35 +1,15 @@
using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.Options;
using Net.Pkcs11Interop.Common;
using Net.Pkcs11Interop.HighLevelAPI;
using Net.Pkcs11Interop.HighLevelAPI.Factories;
using System;
using System.Collections.Concurrent;
using System.Formats.Asn1;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public interface IPkcs11Facade : IDisposable
{
Task<Pkcs11KeyDescriptor> GetKeyAsync(CancellationToken cancellationToken);
Task<Pkcs11PublicKeyMaterial> GetPublicKeyAsync(CancellationToken cancellationToken);
Task<byte[]> SignDigestAsync(ReadOnlyMemory<byte> digest, CancellationToken cancellationToken);
}
public sealed record Pkcs11KeyDescriptor(
string KeyId,
string? Label,
DateTimeOffset CreatedAt);
public sealed record Pkcs11PublicKeyMaterial(
string KeyId,
string Curve,
byte[] Qx,
byte[] Qy);
internal sealed class Pkcs11InteropFacade : IPkcs11Facade
internal sealed partial class Pkcs11InteropFacade : IPkcs11Facade
{
private readonly Pkcs11Options _options;
private readonly Pkcs11InteropFactories _factories;
@@ -53,6 +33,11 @@ internal sealed class Pkcs11InteropFacade : IPkcs11Facade
?? throw new InvalidOperationException("Could not resolve PKCS#11 slot.");
}
public Pkcs11InteropFacade(IOptions<Pkcs11Options> options, TimeProvider timeProvider)
: this(options?.Value ?? new Pkcs11Options(), timeProvider)
{
}
public async Task<Pkcs11KeyDescriptor> GetKeyAsync(CancellationToken cancellationToken)
{
using var context = await OpenSessionAsync(cancellationToken).ConfigureAwait(false);
@@ -119,169 +104,8 @@ internal sealed class Pkcs11InteropFacade : IPkcs11Facade
return session.Sign(mechanism, privateHandle, digest.ToArray());
}
private async Task<SessionContext> OpenSessionAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var session = _slot.OpenSession(SessionType.ReadOnly);
var loggedIn = false;
try
{
if (!string.IsNullOrWhiteSpace(_options.UserPin))
{
session.Login(CKU.CKU_USER, _options.UserPin);
loggedIn = true;
}
return new SessionContext(session, loggedIn);
}
catch
{
if (loggedIn)
{
try { session.Logout(); } catch { /* ignore */ }
}
session.Dispose();
throw;
}
}
private IObjectHandle? FindKey(ISession session, CKO objectClass, string? label)
{
var template = new List<IObjectAttribute>
{
_factories.ObjectAttributeFactory.Create(CKA.CKA_CLASS, (uint)objectClass)
};
if (!string.IsNullOrWhiteSpace(label))
{
template.Add(_factories.ObjectAttributeFactory.Create(CKA.CKA_LABEL, label));
}
var handles = session.FindAllObjects(template);
return handles.FirstOrDefault();
}
private IObjectAttribute? GetAttribute(ISession session, IObjectHandle handle, CKA type)
{
var cacheKey = $"{handle.ObjectId}:{(uint)type}";
if (_attributeCache.TryGetValue(cacheKey, out var cached))
{
return cached.FirstOrDefault();
}
var attributes = session.GetAttributeValue(handle, new List<CKA> { type })
?.ToArray() ?? Array.Empty<IObjectAttribute>();
if (attributes.Length > 0)
{
_attributeCache[cacheKey] = attributes;
return attributes[0];
}
return null;
}
private static ISlot? ResolveSlot(IPkcs11Library pkcs11, Pkcs11Options options)
{
var slots = pkcs11.GetSlotList(SlotsType.WithTokenPresent);
if (slots.Count == 0)
{
return null;
}
if (!string.IsNullOrWhiteSpace(options.SlotId))
{
return slots.FirstOrDefault(slot => string.Equals(slot.SlotId.ToString(), options.SlotId, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(options.TokenLabel))
{
return slots.FirstOrDefault(slot =>
{
var info = slot.GetTokenInfo();
return string.Equals(info.Label?.Trim(), options.TokenLabel.Trim(), StringComparison.Ordinal);
});
}
return slots[0];
}
private static byte[] ExtractEcPoint(byte[] derEncoded)
{
var reader = new AsnReader(derEncoded, AsnEncodingRules.DER);
var point = reader.ReadOctetString();
reader.ThrowIfNotEmpty();
return point;
}
private static (string CurveName, int CoordinateSize) DecodeCurve(byte[] ecParamsDer)
{
var reader = new AsnReader(ecParamsDer, AsnEncodingRules.DER);
var oid = reader.ReadObjectIdentifier();
reader.ThrowIfNotEmpty();
var curve = oid switch
{
"1.2.840.10045.3.1.7" => JsonWebKeyECTypes.P256,
"1.3.132.0.34" => JsonWebKeyECTypes.P384,
"1.3.132.0.35" => JsonWebKeyECTypes.P521,
_ => throw new InvalidOperationException($"Unsupported EC curve OID '{oid}'."),
};
var coordinateSize = curve switch
{
JsonWebKeyECTypes.P256 => 32,
JsonWebKeyECTypes.P384 => 48,
JsonWebKeyECTypes.P521 => 66,
_ => throw new InvalidOperationException($"Unsupported EC curve '{curve}'."),
};
return (curve, coordinateSize);
}
public void Dispose()
{
_library.Dispose();
}
private sealed class SessionContext : System.IDisposable
{
private readonly ISession _session;
private readonly bool _logoutOnDispose;
private bool _disposed;
public SessionContext(ISession session, bool logoutOnDispose)
{
_session = session ?? throw new System.ArgumentNullException(nameof(session));
_logoutOnDispose = logoutOnDispose;
}
public ISession Session => _session;
public void Dispose()
{
if (_disposed)
{
return;
}
if (_logoutOnDispose)
{
try
{
_session.Logout();
}
catch
{
// ignore logout failures
}
}
_session.Dispose();
_disposed = true;
}
}
}
}

View File

@@ -0,0 +1,36 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class Pkcs11KmsClient
{
private async Task<CachedMetadata> GetCachedMetadataAsync(string keyId, CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
if (_metadataCache.TryGetValue(keyId, out var cached) && cached.ExpiresAt > now)
{
return cached;
}
var descriptor = await _facade.GetKeyAsync(cancellationToken).ConfigureAwait(false);
var entry = new CachedMetadata(descriptor, now.Add(_metadataCacheDuration));
_metadataCache[keyId] = entry;
return entry;
}
private async Task<CachedPublicKey> GetCachedPublicKeyAsync(string keyId, CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
if (_publicKeyCache.TryGetValue(keyId, out var cached) && cached.ExpiresAt > now)
{
return cached;
}
var material = await _facade.GetPublicKeyAsync(cancellationToken).ConfigureAwait(false);
var entry = new CachedPublicKey(material, now.Add(_publicKeyCacheDuration));
_publicKeyCache[keyId] = entry;
return entry;
}
}

View File

@@ -0,0 +1,36 @@
using Microsoft.IdentityModel.Tokens;
using System;
using System.Security.Cryptography;
namespace StellaOps.Cryptography.Kms;
public sealed partial class Pkcs11KmsClient
{
private static byte[] ComputeSha256(ReadOnlyMemory<byte> data)
{
var digest = new byte[32];
if (!SHA256.TryHashData(data.Span, digest, out _))
{
throw new InvalidOperationException("Failed to hash payload with SHA-256.");
}
return digest;
}
private static ECCurve ResolveCurve(string curve)
=> curve switch
{
JsonWebKeyECTypes.P256 => ECCurve.NamedCurves.nistP256,
JsonWebKeyECTypes.P384 => ECCurve.NamedCurves.nistP384,
JsonWebKeyECTypes.P521 => ECCurve.NamedCurves.nistP521,
_ => throw new InvalidOperationException($"Unsupported EC curve '{curve}'."),
};
private void ThrowIfDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(Pkcs11KmsClient));
}
}
}

View File

@@ -0,0 +1,25 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class Pkcs11KmsClient
{
public Task<KmsKeyMetadata> RotateAsync(string keyId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("PKCS#11 rotation requires HSM administrative tooling.");
public Task RevokeAsync(string keyId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("PKCS#11 revocation must be handled by HSM policies.");
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_facade.Dispose();
}
}

View File

@@ -0,0 +1,65 @@
using System;
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class Pkcs11KmsClient
{
public async Task<KmsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
var descriptor = await GetCachedMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
var publicMaterial = await GetCachedPublicKeyAsync(keyId, cancellationToken).ConfigureAwait(false);
using var ecdsa = ECDsa.Create(new ECParameters
{
Curve = ResolveCurve(publicMaterial.Material.Curve),
Q =
{
X = publicMaterial.Material.Qx,
Y = publicMaterial.Material.Qy,
},
});
var subjectInfo = Convert.ToBase64String(ecdsa.ExportSubjectPublicKeyInfo());
var version = new KmsKeyVersionMetadata(
descriptor.Descriptor.KeyId,
KmsKeyState.Active,
descriptor.Descriptor.CreatedAt,
null,
subjectInfo,
publicMaterial.Material.Curve);
return new KmsKeyMetadata(
descriptor.Descriptor.KeyId,
KmsAlgorithms.Es256,
KmsKeyState.Active,
descriptor.Descriptor.CreatedAt,
ImmutableArray.Create(version));
}
public async Task<KmsKeyMaterial> ExportAsync(string keyId, string? keyVersion, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
var descriptor = await GetCachedMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
var publicMaterial = await GetCachedPublicKeyAsync(keyId, cancellationToken).ConfigureAwait(false);
return new KmsKeyMaterial(
descriptor.Descriptor.KeyId,
descriptor.Descriptor.KeyId,
KmsAlgorithms.Es256,
publicMaterial.Material.Curve,
Array.Empty<byte>(),
publicMaterial.Material.Qx,
publicMaterial.Material.Qy,
descriptor.Descriptor.CreatedAt);
}
}

View File

@@ -0,0 +1,10 @@
using System;
namespace StellaOps.Cryptography.Kms;
public sealed partial class Pkcs11KmsClient
{
private sealed record CachedMetadata(Pkcs11KeyDescriptor Descriptor, DateTimeOffset ExpiresAt);
private sealed record CachedPublicKey(Pkcs11PublicKeyMaterial Material, DateTimeOffset ExpiresAt);
}

View File

@@ -0,0 +1,77 @@
using System;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class Pkcs11KmsClient
{
public async Task<KmsSignResult> SignAsync(
string keyId,
string? keyVersion,
ReadOnlyMemory<byte> data,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
if (data.IsEmpty)
{
throw new ArgumentException("Signing payload cannot be empty.", nameof(data));
}
var digest = ComputeSha256(data);
try
{
var descriptor = await GetCachedMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
var signature = await _facade.SignDigestAsync(digest, cancellationToken).ConfigureAwait(false);
return new KmsSignResult(
descriptor.Descriptor.KeyId,
descriptor.Descriptor.KeyId,
KmsAlgorithms.Es256,
signature);
}
finally
{
CryptographicOperations.ZeroMemory(digest.AsSpan());
}
}
public async Task<bool> VerifyAsync(
string keyId,
string? keyVersion,
ReadOnlyMemory<byte> data,
ReadOnlyMemory<byte> signature,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
if (data.IsEmpty || signature.IsEmpty)
{
return false;
}
var digest = ComputeSha256(data);
try
{
var publicMaterial = await GetCachedPublicKeyAsync(keyId, cancellationToken).ConfigureAwait(false);
using var ecdsa = ECDsa.Create(new ECParameters
{
Curve = ResolveCurve(publicMaterial.Material.Curve),
Q =
{
X = publicMaterial.Material.Qx,
Y = publicMaterial.Material.Qy,
},
});
return ecdsa.VerifyHash(digest, signature.ToArray());
}
finally
{
CryptographicOperations.ZeroMemory(digest.AsSpan());
}
}
}

View File

@@ -1,15 +1,13 @@
using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.Options;
using System;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Security.Cryptography;
namespace StellaOps.Cryptography.Kms;
/// <summary>
/// PKCS#11-backed implementation of <see cref="IKmsClient"/>.
/// </summary>
public sealed class Pkcs11KmsClient : IKmsClient
public sealed partial class Pkcs11KmsClient : IKmsClient
{
private readonly IPkcs11Facade _facade;
private readonly TimeSpan _metadataCacheDuration;
@@ -30,203 +28,8 @@ public sealed class Pkcs11KmsClient : IKmsClient
_publicKeyCacheDuration = options.PublicKeyCacheDuration;
}
public async Task<KmsSignResult> SignAsync(
string keyId,
string? keyVersion,
ReadOnlyMemory<byte> data,
CancellationToken cancellationToken = default)
public Pkcs11KmsClient(IPkcs11Facade facade, IOptions<Pkcs11Options> options, TimeProvider timeProvider)
: this(facade, options?.Value ?? new Pkcs11Options(), timeProvider)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
if (data.IsEmpty)
{
throw new ArgumentException("Signing payload cannot be empty.", nameof(data));
}
var digest = ComputeSha256(data);
try
{
var descriptor = await GetCachedMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
var signature = await _facade.SignDigestAsync(digest, cancellationToken).ConfigureAwait(false);
return new KmsSignResult(
descriptor.Descriptor.KeyId,
descriptor.Descriptor.KeyId,
KmsAlgorithms.Es256,
signature);
}
finally
{
CryptographicOperations.ZeroMemory(digest.AsSpan());
}
}
public async Task<bool> VerifyAsync(
string keyId,
string? keyVersion,
ReadOnlyMemory<byte> data,
ReadOnlyMemory<byte> signature,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
if (data.IsEmpty || signature.IsEmpty)
{
return false;
}
var digest = ComputeSha256(data);
try
{
var publicMaterial = await GetCachedPublicKeyAsync(keyId, cancellationToken).ConfigureAwait(false);
using var ecdsa = ECDsa.Create(new ECParameters
{
Curve = ResolveCurve(publicMaterial.Material.Curve),
Q =
{
X = publicMaterial.Material.Qx,
Y = publicMaterial.Material.Qy,
},
});
return ecdsa.VerifyHash(digest, signature.ToArray());
}
finally
{
CryptographicOperations.ZeroMemory(digest.AsSpan());
}
}
public async Task<KmsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
var descriptor = await GetCachedMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
var publicMaterial = await GetCachedPublicKeyAsync(keyId, cancellationToken).ConfigureAwait(false);
using var ecdsa = ECDsa.Create(new ECParameters
{
Curve = ResolveCurve(publicMaterial.Material.Curve),
Q =
{
X = publicMaterial.Material.Qx,
Y = publicMaterial.Material.Qy,
},
});
var subjectInfo = Convert.ToBase64String(ecdsa.ExportSubjectPublicKeyInfo());
var version = new KmsKeyVersionMetadata(
descriptor.Descriptor.KeyId,
KmsKeyState.Active,
descriptor.Descriptor.CreatedAt,
null,
subjectInfo,
publicMaterial.Material.Curve);
return new KmsKeyMetadata(
descriptor.Descriptor.KeyId,
KmsAlgorithms.Es256,
KmsKeyState.Active,
descriptor.Descriptor.CreatedAt,
ImmutableArray.Create(version));
}
public async Task<KmsKeyMaterial> ExportAsync(string keyId, string? keyVersion, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
var descriptor = await GetCachedMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
var publicMaterial = await GetCachedPublicKeyAsync(keyId, cancellationToken).ConfigureAwait(false);
return new KmsKeyMaterial(
descriptor.Descriptor.KeyId,
descriptor.Descriptor.KeyId,
KmsAlgorithms.Es256,
publicMaterial.Material.Curve,
Array.Empty<byte>(),
publicMaterial.Material.Qx,
publicMaterial.Material.Qy,
descriptor.Descriptor.CreatedAt);
}
public Task<KmsKeyMetadata> RotateAsync(string keyId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("PKCS#11 rotation requires HSM administrative tooling.");
public Task RevokeAsync(string keyId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("PKCS#11 revocation must be handled by HSM policies.");
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_facade.Dispose();
}
private async Task<CachedMetadata> GetCachedMetadataAsync(string keyId, CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
if (_metadataCache.TryGetValue(keyId, out var cached) && cached.ExpiresAt > now)
{
return cached;
}
var descriptor = await _facade.GetKeyAsync(cancellationToken).ConfigureAwait(false);
var entry = new CachedMetadata(descriptor, now.Add(_metadataCacheDuration));
_metadataCache[keyId] = entry;
return entry;
}
private async Task<CachedPublicKey> GetCachedPublicKeyAsync(string keyId, CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
if (_publicKeyCache.TryGetValue(keyId, out var cached) && cached.ExpiresAt > now)
{
return cached;
}
var material = await _facade.GetPublicKeyAsync(cancellationToken).ConfigureAwait(false);
var entry = new CachedPublicKey(material, now.Add(_publicKeyCacheDuration));
_publicKeyCache[keyId] = entry;
return entry;
}
private static byte[] ComputeSha256(ReadOnlyMemory<byte> data)
{
var digest = new byte[32];
if (!SHA256.TryHashData(data.Span, digest, out _))
{
throw new InvalidOperationException("Failed to hash payload with SHA-256.");
}
return digest;
}
private static ECCurve ResolveCurve(string curve)
=> curve switch
{
JsonWebKeyECTypes.P256 => ECCurve.NamedCurves.nistP256,
JsonWebKeyECTypes.P384 => ECCurve.NamedCurves.nistP384,
JsonWebKeyECTypes.P521 => ECCurve.NamedCurves.nistP521,
_ => throw new InvalidOperationException($"Unsupported EC curve '{curve}'."),
};
private void ThrowIfDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(Pkcs11KmsClient));
}
}
private sealed record CachedMetadata(Pkcs11KeyDescriptor Descriptor, DateTimeOffset ExpiresAt);
private sealed record CachedPublicKey(Pkcs11PublicKeyMaterial Material, DateTimeOffset ExpiresAt);
}

View File

@@ -5,8 +5,8 @@ namespace StellaOps.Cryptography.Kms;
/// </summary>
public sealed class Pkcs11Options
{
private TimeSpan metadataCacheDuration = TimeSpan.FromMinutes(5);
private TimeSpan publicKeyCacheDuration = TimeSpan.FromMinutes(5);
private TimeSpan _metadataCacheDuration = TimeSpan.FromMinutes(5);
private TimeSpan _publicKeyCacheDuration = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets or sets the native PKCS#11 library path.
@@ -48,8 +48,8 @@ public sealed class Pkcs11Options
/// </summary>
public TimeSpan MetadataCacheDuration
{
get => metadataCacheDuration;
set => metadataCacheDuration = EnsurePositive(value, TimeSpan.FromMinutes(5));
get => _metadataCacheDuration;
set => _metadataCacheDuration = EnsurePositive(value, TimeSpan.FromMinutes(5));
}
/// <summary>
@@ -57,15 +57,10 @@ public sealed class Pkcs11Options
/// </summary>
public TimeSpan PublicKeyCacheDuration
{
get => publicKeyCacheDuration;
set => publicKeyCacheDuration = EnsurePositive(value, TimeSpan.FromMinutes(5));
get => _publicKeyCacheDuration;
set => _publicKeyCacheDuration = EnsurePositive(value, TimeSpan.FromMinutes(5));
}
/// <summary>
/// Gets or sets an optional factory for advanced facade injection (testing, custom providers).
/// </summary>
public Func<IServiceProvider, IPkcs11Facade>? FacadeFactory { get; set; }
private static TimeSpan EnsurePositive(TimeSpan value, TimeSpan fallback)
=> value <= TimeSpan.Zero ? fallback : value;
}

View File

@@ -0,0 +1,24 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using System;
namespace StellaOps.Cryptography.Kms;
public static partial class ServiceCollectionExtensions
{
public static IServiceCollection AddAwsKms(
this IServiceCollection services,
Action<AwsKmsOptions> configure)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
RemoveKmsServices(services);
services.Configure(configure);
RegisterKmsProvider(services);
services.TryAddSingleton<IAwsKmsFacade, AwsKmsFacade>();
services.TryAddSingleton<IKmsClient, AwsKmsClient>();
return services;
}
}

View File

@@ -0,0 +1,24 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using System;
namespace StellaOps.Cryptography.Kms;
public static partial class ServiceCollectionExtensions
{
public static IServiceCollection AddFido2Kms(
this IServiceCollection services,
Action<Fido2Options> configure)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
RemoveKmsServices(services);
services.Configure(configure);
RegisterKmsProvider(services);
services.TryAddSingleton<IFido2Authenticator, MissingFido2Authenticator>();
services.TryAddSingleton<IKmsClient, Fido2KmsClient>();
return services;
}
}

View File

@@ -0,0 +1,23 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using System;
namespace StellaOps.Cryptography.Kms;
public static partial class ServiceCollectionExtensions
{
public static IServiceCollection AddFileKms(
this IServiceCollection services,
Action<FileKmsOptions> configure)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
RemoveKmsServices(services);
services.Configure(configure);
RegisterKmsProvider(services);
services.TryAddSingleton<IKmsClient, FileKmsClient>();
return services;
}
}

View File

@@ -0,0 +1,24 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using System;
namespace StellaOps.Cryptography.Kms;
public static partial class ServiceCollectionExtensions
{
public static IServiceCollection AddGcpKms(
this IServiceCollection services,
Action<GcpKmsOptions> configure)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
RemoveKmsServices(services);
services.Configure(configure);
RegisterKmsProvider(services);
services.TryAddSingleton<IGcpKmsFacade, GcpKmsFacade>();
services.TryAddSingleton<IKmsClient, GcpKmsClient>();
return services;
}
}

View File

@@ -0,0 +1,24 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using System;
namespace StellaOps.Cryptography.Kms;
public static partial class ServiceCollectionExtensions
{
public static IServiceCollection AddPkcs11Kms(
this IServiceCollection services,
Action<Pkcs11Options> configure)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
RemoveKmsServices(services);
services.Configure(configure);
RegisterKmsProvider(services);
services.TryAddSingleton<IPkcs11Facade, Pkcs11InteropFacade>();
services.TryAddSingleton<IKmsClient, Pkcs11KmsClient>();
return services;
}
}

View File

@@ -1,167 +1,22 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using System;
namespace StellaOps.Cryptography.Kms;
/// <summary>
/// Dependency injection helpers for the KMS client and crypto provider.
/// </summary>
public static class ServiceCollectionExtensions
public static partial class ServiceCollectionExtensions
{
public static IServiceCollection AddFileKms(
this IServiceCollection services,
Action<FileKmsOptions> configure)
private static void RemoveKmsServices(IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
services.RemoveAll<IKmsClient>();
services.RemoveAll<IAwsKmsFacade>();
services.RemoveAll<IGcpKmsFacade>();
services.RemoveAll<IPkcs11Facade>();
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;
}
public static IServiceCollection AddAwsKms(
this IServiceCollection services,
Action<AwsKmsOptions> configure)
private static void RegisterKmsProvider(IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
services.RemoveAll<IKmsClient>();
services.RemoveAll<IAwsKmsFacade>();
services.RemoveAll<IGcpKmsFacade>();
services.RemoveAll<IPkcs11Facade>();
services.Configure(configure);
services.AddSingleton<IAwsKmsFacade>(sp =>
{
var options = sp.GetRequiredService<IOptions<AwsKmsOptions>>().Value ?? new AwsKmsOptions();
return options.FacadeFactory?.Invoke(sp) ?? new AwsKmsFacade(options);
});
services.AddSingleton<IKmsClient>(sp =>
{
var options = sp.GetRequiredService<IOptions<AwsKmsOptions>>().Value ?? new AwsKmsOptions();
var facade = sp.GetRequiredService<IAwsKmsFacade>();
return new AwsKmsClient(facade, options);
});
services.TryAddSingleton(TimeProvider.System);
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, KmsCryptoProvider>());
return services;
}
public static IServiceCollection AddGcpKms(
this IServiceCollection services,
Action<GcpKmsOptions> configure)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
services.RemoveAll<IKmsClient>();
services.RemoveAll<IAwsKmsFacade>();
services.RemoveAll<IGcpKmsFacade>();
services.RemoveAll<IPkcs11Facade>();
services.Configure(configure);
services.AddSingleton<IGcpKmsFacade>(sp =>
{
var options = sp.GetRequiredService<IOptions<GcpKmsOptions>>().Value ?? new GcpKmsOptions();
return options.FacadeFactory?.Invoke(sp) ?? new GcpKmsFacade(options);
});
services.AddSingleton<IKmsClient>(sp =>
{
var options = sp.GetRequiredService<IOptions<GcpKmsOptions>>().Value ?? new GcpKmsOptions();
var facade = sp.GetRequiredService<IGcpKmsFacade>();
return new GcpKmsClient(facade, options);
});
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, KmsCryptoProvider>());
return services;
}
public static IServiceCollection AddPkcs11Kms(
this IServiceCollection services,
Action<Pkcs11Options> configure)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
services.RemoveAll<IKmsClient>();
services.RemoveAll<IAwsKmsFacade>();
services.RemoveAll<IGcpKmsFacade>();
services.RemoveAll<IPkcs11Facade>();
services.Configure(configure);
services.AddSingleton<IPkcs11Facade>(sp =>
{
var options = sp.GetRequiredService<IOptions<Pkcs11Options>>().Value ?? new Pkcs11Options();
return options.FacadeFactory?.Invoke(sp) ?? new Pkcs11InteropFacade(options);
});
services.AddSingleton<IKmsClient>(sp =>
{
var options = sp.GetRequiredService<IOptions<Pkcs11Options>>().Value ?? new Pkcs11Options();
var facade = sp.GetRequiredService<IPkcs11Facade>();
return new Pkcs11KmsClient(facade, options);
});
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, KmsCryptoProvider>());
return services;
}
public static IServiceCollection AddFido2Kms(
this IServiceCollection services,
Action<Fido2Options> configure)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
services.RemoveAll<IKmsClient>();
services.Configure(configure);
services.TryAddSingleton<IFido2Authenticator>(sp =>
{
var options = sp.GetRequiredService<IOptions<Fido2Options>>().Value ?? new Fido2Options();
if (options.AuthenticatorFactory is null)
{
throw new InvalidOperationException("Fido2Options.AuthenticatorFactory must be provided or IFido2Authenticator registered separately.");
}
return options.AuthenticatorFactory(sp);
});
services.AddSingleton<IKmsClient>(sp =>
{
var options = sp.GetRequiredService<IOptions<Fido2Options>>().Value ?? new Fido2Options();
var authenticator = sp.GetRequiredService<IFido2Authenticator>();
return new Fido2KmsClient(authenticator, options);
});
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, KmsCryptoProvider>());
return services;
}
}
}

View File

@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0051-T | DONE | Revalidated 2026-01-08. |
| AUDIT-0051-A | TODO | Revalidated 2026-01-08 (open findings). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| REMED-05 | DONE | Async naming + file splits <= 100 lines; service locator removal; KMS public key handling updated; `dotnet test src/__Libraries/__Tests/StellaOps.Cryptography.Kms.Tests/StellaOps.Cryptography.Kms.Tests.csproj -p:BuildInParallel=false -p:UseSharedCompilation=false` (9 tests, MTP0001 warning) and `dotnet test src/__Libraries/__Tests/StellaOps.Cryptography.Tests/StellaOps.Cryptography.Tests.csproj -p:BuildInParallel=false -p:UseSharedCompilation=false` (326 tests) passed 2026-02-04. |