using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Cryptography.Kms;
///
/// Google Cloud KMS implementation of .
///
public sealed class GcpKmsClient : IKmsClient, IDisposable
{
private readonly IGcpKmsFacade _facade;
private readonly TimeSpan _metadataCacheDuration;
private readonly TimeSpan _publicKeyCacheDuration;
private readonly ConcurrentDictionary _metadataCache = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary _publicKeyCache = new(StringComparer.Ordinal);
private bool _disposed;
public GcpKmsClient(IGcpKmsFacade facade, GcpKmsOptions options)
{
_facade = facade ?? throw new ArgumentNullException(nameof(facade));
ArgumentNullException.ThrowIfNull(options);
_metadataCacheDuration = options.MetadataCacheDuration;
_publicKeyCacheDuration = options.PublicKeyCacheDuration;
}
public async Task SignAsync(
string keyId,
string? keyVersion,
ReadOnlyMemory 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 VerifyAsync(
string keyId,
string? keyVersion,
ReadOnlyMemory data,
ReadOnlyMemory 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 GetMetadataAsync(string keyId, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
var snapshot = await GetCachedMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
var versions = ImmutableArray.CreateBuilder(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 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(),
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 RotateAsync(string keyId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Google Cloud KMS rotation must be managed via Cloud KMS rotation schedules.");
public Task RevokeAsync(string keyId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Google Cloud KMS key revocation must be managed via Cloud KMS destroy/disable operations.");
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_facade.Dispose();
}
private async Task GetCachedMetadataAsync(string keyId, CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
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 GetCachedPublicKeyAsync(string versionName, CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
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 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 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 Versions);
private sealed record GcpPublicMaterial(string VersionName, string Algorithm, byte[] SubjectPublicKeyInfo);
}