part #2
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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}'."),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}'."),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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."),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,3 +2,4 @@ using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Cryptography.Kms.Tests")]
|
||||
|
||||
namespace StellaOps.Cryptography.Kms;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user