using System.Collections.Immutable; using System.Security.Cryptography; using Microsoft.IdentityModel.Tokens; namespace StellaOps.Cryptography.Kms; /// /// FIDO2-backed KMS client suitable for high-assurance interactive workflows. /// public sealed class Fido2KmsClient : IKmsClient { private readonly IFido2Authenticator _authenticator; private readonly Fido2Options _options; private readonly ECParameters _publicParameters; private readonly byte[] _subjectPublicKeyInfo; private readonly TimeSpan _metadataCacheDuration; private readonly string _curveName; private KmsKeyMetadata? _cachedMetadata; private DateTimeOffset _metadataExpiresAt; private bool _disposed; public Fido2KmsClient(IFido2Authenticator authenticator, Fido2Options options) { _authenticator = authenticator ?? throw new ArgumentNullException(nameof(authenticator)); _options = options ?? throw new ArgumentNullException(nameof(options)); if (string.IsNullOrWhiteSpace(_options.CredentialId)) { throw new ArgumentException("Credential identifier must be provided.", nameof(options)); } if (string.IsNullOrWhiteSpace(_options.PublicKeyPem)) { throw new ArgumentException("Public key PEM must be provided.", nameof(options)); } _metadataCacheDuration = options.MetadataCacheDuration <= TimeSpan.Zero ? TimeSpan.FromMinutes(5) : options.MetadataCacheDuration; using var ecdsa = ECDsa.Create(); ecdsa.ImportFromPem(_options.PublicKeyPem); _publicParameters = ecdsa.ExportParameters(false); _subjectPublicKeyInfo = ecdsa.ExportSubjectPublicKeyInfo(); _curveName = ResolveCurveName(_publicParameters.Curve); } public async Task SignAsync( string keyId, string? keyVersion, ReadOnlyMemory 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 VerifyAsync( string keyId, string? keyVersion, ReadOnlyMemory data, ReadOnlyMemory 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 GetMetadataAsync(string keyId, CancellationToken cancellationToken = default) { ThrowIfDisposed(); var now = DateTimeOffset.UtcNow; if (_cachedMetadata is not null && _metadataExpiresAt > now) { return Task.FromResult(_cachedMetadata); } var version = new KmsKeyVersionMetadata( _options.CredentialId, KmsKeyState.Active, _options.CreatedAt, null, Convert.ToBase64String(_subjectPublicKeyInfo), _curveName); _cachedMetadata = new KmsKeyMetadata( _options.CredentialId, KmsAlgorithms.Es256, KmsKeyState.Active, _options.CreatedAt, ImmutableArray.Create(version)); _metadataExpiresAt = now.Add(_metadataCacheDuration); return Task.FromResult(_cachedMetadata); } public async Task 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(), _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); } public Task 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 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}'."), }; } }