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); }