397 lines
16 KiB
C#
397 lines
16 KiB
C#
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<string, string?>(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<byte>();
|
|
public byte[] LastVerifyDigest { get; private set; } = Array.Empty<byte>();
|
|
|
|
public Task<AwsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken)
|
|
=> Task.FromResult(new AwsKeyMetadata(KeyId, KeyId, DateTimeOffset.UtcNow, AwsKeyStatus.Enabled));
|
|
|
|
public Task<AwsPublicKeyMaterial> GetPublicKeyAsync(string keyResource, CancellationToken cancellationToken)
|
|
=> Task.FromResult(new AwsPublicKeyMaterial(KeyId, VersionId, "ECC_NIST_P256", _fixture.PublicSubjectInfo));
|
|
|
|
public Task<AwsSignResult> SignAsync(string keyResource, ReadOnlyMemory<byte> digest, CancellationToken cancellationToken)
|
|
{
|
|
LastDigest = digest.ToArray();
|
|
var signature = _fixture.SignDigest(digest.Span);
|
|
return Task.FromResult(new AwsSignResult(KeyId, VersionId, signature));
|
|
}
|
|
|
|
public Task<bool> VerifyAsync(string keyResource, ReadOnlyMemory<byte> digest, ReadOnlyMemory<byte> 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<byte>();
|
|
|
|
public Task<GcpCryptoKeyMetadata> GetCryptoKeyMetadataAsync(string keyName, CancellationToken cancellationToken)
|
|
=> Task.FromResult(new GcpCryptoKeyMetadata(KeyName, PrimaryVersion, DateTimeOffset.UtcNow));
|
|
|
|
public Task<IReadOnlyList<GcpCryptoKeyVersionMetadata>> ListKeyVersionsAsync(string keyName, CancellationToken cancellationToken)
|
|
{
|
|
IReadOnlyList<GcpCryptoKeyVersionMetadata> 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<GcpPublicKeyMaterial> GetPublicKeyAsync(string versionName, CancellationToken cancellationToken)
|
|
{
|
|
var pem = ToPem(_fixture.PublicSubjectInfo);
|
|
return Task.FromResult(new GcpPublicKeyMaterial(versionName, "EC_SIGN_P256_SHA256", pem));
|
|
}
|
|
|
|
public Task<GcpSignResult> SignAsync(string versionName, ReadOnlyMemory<byte> 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<byte>();
|
|
|
|
public Task<Pkcs11KeyDescriptor> GetKeyAsync(CancellationToken cancellationToken)
|
|
=> Task.FromResult(new Pkcs11KeyDescriptor(KeyId, "attestor", DateTimeOffset.UtcNow.AddDays(-7)));
|
|
|
|
public Task<Pkcs11PublicKeyMaterial> GetPublicKeyAsync(CancellationToken cancellationToken)
|
|
=> Task.FromResult(new Pkcs11PublicKeyMaterial(KeyId, JsonWebKeyECTypes.P256, _fixture.Parameters.Q.X!, _fixture.Parameters.Q.Y!));
|
|
|
|
public Task<byte[]> SignDigestAsync(ReadOnlyMemory<byte> 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<byte>();
|
|
|
|
public Task<byte[]> SignAsync(string credentialId, ReadOnlyMemory<byte> 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<KmsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult(new KmsKeyMetadata(keyId, KmsAlgorithms.Es256, KmsKeyState.Active, DateTimeOffset.UtcNow, ImmutableArray<KmsKeyVersionMetadata>.Empty));
|
|
|
|
public Task<KmsKeyMaterial> ExportAsync(string keyId, string? keyVersion, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult(new KmsKeyMaterial(
|
|
keyId,
|
|
keyVersion ?? keyId,
|
|
KmsAlgorithms.Es256,
|
|
JsonWebKeyECTypes.P256,
|
|
Array.Empty<byte>(),
|
|
_parameters.Q.X ?? throw new InvalidOperationException("Qx missing."),
|
|
_parameters.Q.Y ?? throw new InvalidOperationException("Qy missing."),
|
|
DateTimeOffset.UtcNow));
|
|
|
|
public Task<KmsSignResult> SignAsync(string keyId, string? keyVersion, ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default)
|
|
=> throw new NotSupportedException();
|
|
|
|
public Task<bool> VerifyAsync(string keyId, string? keyVersion, ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken = default)
|
|
=> throw new NotSupportedException();
|
|
|
|
public Task<KmsKeyMetadata> 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<byte> digest) => _ecdsa.SignHash(digest.ToArray());
|
|
|
|
public bool VerifyDigest(ReadOnlySpan<byte> digest, ReadOnlySpan<byte> signature)
|
|
=> _ecdsa.VerifyHash(digest.ToArray(), signature.ToArray());
|
|
|
|
public void Dispose()
|
|
{
|
|
_ecdsa.Dispose();
|
|
}
|
|
}
|
|
}
|