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}'."),
};
}
}