using System.Collections.Immutable; using System.Linq; using System.Security.Cryptography; using System.Text; using Microsoft.IdentityModel.Tokens; using StellaOps.Cryptography; using StellaOps.Cryptography.Kms; using StellaOps.TestKit; namespace StellaOps.Cryptography.Kms.Tests; public sealed class CloudKmsClientTests { [Trait("Category", TestCategories.Unit)] [Fact] public async Task AwsClient_Signs_Verifies_And_Exports_Metadata() { using var fixture = new EcdsaFixture(); var facade = new TestAwsFacade(fixture); var client = new AwsKmsClient(facade, new AwsKmsOptions { MetadataCacheDuration = TimeSpan.FromMinutes(30), PublicKeyCacheDuration = TimeSpan.FromMinutes(30), }); var payload = Encoding.UTF8.GetBytes("stella-ops"); var expectedDigest = SHA256.HashData(payload); var signResult = await client.SignAsync(facade.KeyId, facade.VersionId, payload); Assert.Equal(KmsAlgorithms.Es256, signResult.Algorithm); Assert.Equal(facade.VersionId, signResult.VersionId); Assert.NotEmpty(signResult.Signature); Assert.Equal(expectedDigest, facade.LastDigest); var verified = await client.VerifyAsync(facade.KeyId, facade.VersionId, payload, signResult.Signature); Assert.True(verified); Assert.Equal(expectedDigest, facade.LastVerifyDigest); var metadata = await client.GetMetadataAsync(facade.KeyId); Assert.Equal(facade.KeyId, metadata.KeyId); Assert.Equal(KmsAlgorithms.Es256, metadata.Algorithm); Assert.Equal(KmsKeyState.Active, metadata.State); Assert.Single(metadata.Versions); var version = metadata.Versions[0]; Assert.Equal(facade.VersionId, version.VersionId); Assert.Equal(JsonWebKeyECTypes.P256, version.Curve); Assert.Equal(Convert.ToBase64String(fixture.PublicSubjectInfo), version.PublicKey); var exported = await client.ExportAsync(facade.KeyId, facade.VersionId); Assert.Equal(facade.KeyId, exported.KeyId); Assert.Equal(facade.VersionId, exported.VersionId); Assert.Empty(exported.D); Assert.Equal(fixture.Parameters.Q.X, exported.Qx); Assert.Equal(fixture.Parameters.Q.Y, exported.Qy); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GcpClient_Uses_Primary_When_Version_Not_Specified() { using var fixture = new EcdsaFixture(); var facade = new TestGcpFacade(fixture); var client = new GcpKmsClient(facade, new GcpKmsOptions { MetadataCacheDuration = TimeSpan.FromMinutes(30), PublicKeyCacheDuration = TimeSpan.FromMinutes(30), }); var payload = Encoding.UTF8.GetBytes("cloud-gcp"); var expectedDigest = SHA256.HashData(payload); var signResult = await client.SignAsync(facade.KeyName, keyVersion: null, payload); Assert.Equal(facade.PrimaryVersion, signResult.VersionId); Assert.Equal(expectedDigest, facade.LastDigest); var verified = await client.VerifyAsync(facade.KeyName, null, payload, signResult.Signature); Assert.True(verified); var metadata = await client.GetMetadataAsync(facade.KeyName); Assert.Equal(facade.KeyName, metadata.KeyId); Assert.Equal(KmsKeyState.Active, metadata.State); Assert.Equal(2, metadata.Versions.Length); var primaryVersion = metadata.Versions.First(v => v.VersionId == facade.PrimaryVersion); Assert.Equal(JsonWebKeyECTypes.P256, primaryVersion.Curve); Assert.Equal(Convert.ToBase64String(fixture.PublicSubjectInfo), primaryVersion.PublicKey); var exported = await client.ExportAsync(facade.KeyName, null); Assert.Equal(facade.PrimaryVersion, exported.VersionId); Assert.Empty(exported.D); Assert.Equal(fixture.Parameters.Q.X, exported.Qx); Assert.Equal(fixture.Parameters.Q.Y, exported.Qy); } [Trait("Category", TestCategories.Unit)] [Fact] public void KmsCryptoProvider_Skips_NonExportable_Keys() { using var fixture = new EcdsaFixture(); var kmsClient = new NonExportingKmsClient(fixture.Parameters); var provider = new KmsCryptoProvider(kmsClient); var signingKey = new CryptoSigningKey( new CryptoKeyReference("arn:aws:kms:us-east-1:123456789012:key/demo", "kms"), KmsAlgorithms.Es256, in fixture.Parameters, DateTimeOffset.UtcNow, metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) { ["kms.version"] = "arn:aws:kms:us-east-1:123456789012:key/demo", }); provider.UpsertSigningKey(signingKey); var keys = provider.GetSigningKeys(); Assert.Empty(keys); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task Pkcs11Client_Signs_Verifies_And_Exports() { using var fixture = new EcdsaFixture(); var facade = new TestPkcs11Facade(fixture); var client = new Pkcs11KmsClient(facade, new Pkcs11Options { MetadataCacheDuration = TimeSpan.FromMinutes(15), PublicKeyCacheDuration = TimeSpan.FromMinutes(15), }); var payload = Encoding.UTF8.GetBytes("pkcs11"); var expectedDigest = SHA256.HashData(payload); var signResult = await client.SignAsync("ignored", null, payload); Assert.Equal(KmsAlgorithms.Es256, signResult.Algorithm); Assert.Equal(facade.KeyId, signResult.KeyId); Assert.NotEmpty(signResult.Signature); Assert.Equal(expectedDigest, facade.LastDigest); var verified = await client.VerifyAsync("ignored", null, payload, signResult.Signature); Assert.True(verified); var metadata = await client.GetMetadataAsync("ignored"); Assert.Equal(facade.KeyId, metadata.KeyId); Assert.Equal(KmsKeyState.Active, metadata.State); var version = Assert.Single(metadata.Versions); Assert.Equal(JsonWebKeyECTypes.P256, version.Curve); Assert.Equal(Convert.ToBase64String(fixture.PublicSubjectInfo), version.PublicKey); var exported = await client.ExportAsync("ignored", null); Assert.Equal(facade.KeyId, exported.KeyId); Assert.Empty(exported.D); Assert.Equal(fixture.Parameters.Q.X, exported.Qx); Assert.Equal(fixture.Parameters.Q.Y, exported.Qy); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task Fido2Client_Signs_Verifies_And_Exports() { using var fixture = new EcdsaFixture(); using StellaOps.TestKit; var authenticator = new TestFidoAuthenticator(fixture); var options = new Fido2Options { CredentialId = "cred-demo", RelyingPartyId = "stellaops.test", PublicKeyPem = TestGcpFacade.ToPem(fixture.PublicSubjectInfo), CreatedAt = DateTimeOffset.UtcNow.AddDays(-1), MetadataCacheDuration = TimeSpan.FromMinutes(10), }; var client = new Fido2KmsClient(authenticator, options); var payload = Encoding.UTF8.GetBytes("fido2-data"); var expectedDigest = SHA256.HashData(payload); var signResult = await client.SignAsync(options.CredentialId, null, payload); Assert.Equal(KmsAlgorithms.Es256, signResult.Algorithm); Assert.NotEmpty(signResult.Signature); Assert.Equal(expectedDigest, authenticator.LastDigest); var verified = await client.VerifyAsync(options.CredentialId, null, payload, signResult.Signature); Assert.True(verified); var metadata = await client.GetMetadataAsync(options.CredentialId); Assert.Equal(options.CredentialId, metadata.KeyId); Assert.Equal(KmsKeyState.Active, metadata.State); var version = Assert.Single(metadata.Versions); Assert.Equal(JsonWebKeyECTypes.P256, version.Curve); Assert.Equal(Convert.ToBase64String(fixture.PublicSubjectInfo), version.PublicKey); var material = await client.ExportAsync(options.CredentialId, null); Assert.Empty(material.D); Assert.Equal(fixture.Parameters.Q.X, material.Qx); Assert.Equal(fixture.Parameters.Q.Y, material.Qy); } private sealed class TestAwsFacade : IAwsKmsFacade { private readonly EcdsaFixture _fixture; public TestAwsFacade(EcdsaFixture fixture) { _fixture = fixture; } public string KeyId { get; } = "arn:aws:kms:us-east-1:111122223333:key/demo"; public string VersionId { get; } = "arn:aws:kms:us-east-1:111122223333:key/demo/123"; public byte[] LastDigest { get; private set; } = Array.Empty(); public byte[] LastVerifyDigest { get; private set; } = Array.Empty(); public Task GetMetadataAsync(string keyId, CancellationToken cancellationToken) => Task.FromResult(new AwsKeyMetadata(KeyId, KeyId, DateTimeOffset.UtcNow, AwsKeyStatus.Enabled)); public Task GetPublicKeyAsync(string keyResource, CancellationToken cancellationToken) => Task.FromResult(new AwsPublicKeyMaterial(KeyId, VersionId, "ECC_NIST_P256", _fixture.PublicSubjectInfo)); public Task SignAsync(string keyResource, ReadOnlyMemory digest, CancellationToken cancellationToken) { LastDigest = digest.ToArray(); var signature = _fixture.SignDigest(digest.Span); return Task.FromResult(new AwsSignResult(KeyId, VersionId, signature)); } public Task VerifyAsync(string keyResource, ReadOnlyMemory digest, ReadOnlyMemory signature, CancellationToken cancellationToken) { LastVerifyDigest = digest.ToArray(); return Task.FromResult(_fixture.VerifyDigest(digest.Span, signature.Span)); } public void Dispose() { } } private sealed class TestGcpFacade : IGcpKmsFacade { private readonly EcdsaFixture _fixture; public TestGcpFacade(EcdsaFixture fixture) { _fixture = fixture; } public string KeyName { get; } = "projects/demo/locations/global/keyRings/sample/cryptoKeys/attestor"; public string PrimaryVersion { get; } = "projects/demo/locations/global/keyRings/sample/cryptoKeys/attestor/cryptoKeyVersions/1"; public string SecondaryVersion { get; } = "projects/demo/locations/global/keyRings/sample/cryptoKeys/attestor/cryptoKeyVersions/2"; public byte[] LastDigest { get; private set; } = Array.Empty(); public Task GetCryptoKeyMetadataAsync(string keyName, CancellationToken cancellationToken) => Task.FromResult(new GcpCryptoKeyMetadata(KeyName, PrimaryVersion, DateTimeOffset.UtcNow)); public Task> ListKeyVersionsAsync(string keyName, CancellationToken cancellationToken) { IReadOnlyList versions = new[] { new GcpCryptoKeyVersionMetadata(PrimaryVersion, GcpCryptoKeyVersionState.Enabled, DateTimeOffset.UtcNow.AddDays(-2), null), new GcpCryptoKeyVersionMetadata(SecondaryVersion, GcpCryptoKeyVersionState.Disabled, DateTimeOffset.UtcNow.AddDays(-10), DateTimeOffset.UtcNow.AddDays(-1)), }; return Task.FromResult(versions); } public Task GetPublicKeyAsync(string versionName, CancellationToken cancellationToken) { var pem = ToPem(_fixture.PublicSubjectInfo); return Task.FromResult(new GcpPublicKeyMaterial(versionName, "EC_SIGN_P256_SHA256", pem)); } public Task SignAsync(string versionName, ReadOnlyMemory digest, CancellationToken cancellationToken) { LastDigest = digest.ToArray(); var signature = _fixture.SignDigest(digest.Span); return Task.FromResult(new GcpSignResult(versionName, signature)); } public void Dispose() { } internal static string ToPem(byte[] subjectPublicKeyInfo) { var builder = new StringBuilder(); builder.AppendLine("-----BEGIN PUBLIC KEY-----"); builder.AppendLine(Convert.ToBase64String(subjectPublicKeyInfo, Base64FormattingOptions.InsertLineBreaks)); builder.AppendLine("-----END PUBLIC KEY-----"); return builder.ToString(); } } private sealed class TestPkcs11Facade : IPkcs11Facade { private readonly EcdsaFixture _fixture; public TestPkcs11Facade(EcdsaFixture fixture) { _fixture = fixture; } public string KeyId { get; } = "pkcs11-key-1"; public byte[] LastDigest { get; private set; } = Array.Empty(); public Task GetKeyAsync(CancellationToken cancellationToken) => Task.FromResult(new Pkcs11KeyDescriptor(KeyId, "attestor", DateTimeOffset.UtcNow.AddDays(-7))); public Task GetPublicKeyAsync(CancellationToken cancellationToken) => Task.FromResult(new Pkcs11PublicKeyMaterial(KeyId, JsonWebKeyECTypes.P256, _fixture.Parameters.Q.X!, _fixture.Parameters.Q.Y!)); public Task SignDigestAsync(ReadOnlyMemory digest, CancellationToken cancellationToken) { LastDigest = digest.ToArray(); return Task.FromResult(_fixture.SignDigest(digest.Span)); } public void Dispose() { } } private sealed class TestFidoAuthenticator : IFido2Authenticator { private readonly EcdsaFixture _fixture; public TestFidoAuthenticator(EcdsaFixture fixture) { _fixture = fixture; } public byte[] LastDigest { get; private set; } = Array.Empty(); public Task SignAsync(string credentialId, ReadOnlyMemory digest, CancellationToken cancellationToken = default) { LastDigest = digest.ToArray(); return Task.FromResult(_fixture.SignDigest(digest.Span)); } } private sealed class NonExportingKmsClient : IKmsClient { private readonly ECParameters _parameters; public NonExportingKmsClient(ECParameters parameters) { _parameters = parameters; } public Task GetMetadataAsync(string keyId, CancellationToken cancellationToken = default) => Task.FromResult(new KmsKeyMetadata(keyId, KmsAlgorithms.Es256, KmsKeyState.Active, DateTimeOffset.UtcNow, ImmutableArray.Empty)); public Task ExportAsync(string keyId, string? keyVersion, CancellationToken cancellationToken = default) => Task.FromResult(new KmsKeyMaterial( keyId, keyVersion ?? keyId, KmsAlgorithms.Es256, JsonWebKeyECTypes.P256, Array.Empty(), _parameters.Q.X ?? throw new InvalidOperationException("Qx missing."), _parameters.Q.Y ?? throw new InvalidOperationException("Qy missing."), DateTimeOffset.UtcNow)); public Task SignAsync(string keyId, string? keyVersion, ReadOnlyMemory data, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task VerifyAsync(string keyId, string? keyVersion, ReadOnlyMemory data, ReadOnlyMemory signature, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task RotateAsync(string keyId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task RevokeAsync(string keyId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } private sealed class EcdsaFixture : IDisposable { private readonly ECDsa _ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); public ECParameters Parameters => _ecdsa.ExportParameters(true); public byte[] PublicSubjectInfo => _ecdsa.ExportSubjectPublicKeyInfo(); public byte[] SignDigest(ReadOnlySpan digest) => _ecdsa.SignHash(digest.ToArray()); public bool VerifyDigest(ReadOnlySpan digest, ReadOnlySpan signature) => _ecdsa.VerifyHash(digest.ToArray(), signature.ToArray()); public void Dispose() { _ecdsa.Dispose(); } } }