Add unit tests for SBOM ingestion and transformation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Implement `SbomIngestServiceCollectionExtensionsTests` to verify the SBOM ingestion pipeline exports snapshots correctly. - Create `SbomIngestTransformerTests` to ensure the transformation produces expected nodes and edges, including deduplication of license nodes and normalization of timestamps. - Add `SbomSnapshotExporterTests` to test the export functionality for manifest, adjacency, nodes, and edges. - Introduce `VexOverlayTransformerTests` to validate the transformation of VEX nodes and edges. - Set up project file for the test project with necessary dependencies and configurations. - Include JSON fixture files for testing purposes.
This commit is contained in:
248
src/__Libraries/StellaOps.Cryptography.Kms/AwsKmsClient.cs
Normal file
248
src/__Libraries/StellaOps.Cryptography.Kms/AwsKmsClient.cs
Normal file
@@ -0,0 +1,248 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace StellaOps.Cryptography.Kms;
|
||||
|
||||
/// <summary>
|
||||
/// AWS KMS implementation of <see cref="IKmsClient"/>.
|
||||
/// </summary>
|
||||
public sealed class AwsKmsClient : IKmsClient, IDisposable
|
||||
{
|
||||
private readonly IAwsKmsFacade _facade;
|
||||
private readonly TimeSpan _metadataCacheDuration;
|
||||
private readonly TimeSpan _publicKeyCacheDuration;
|
||||
|
||||
private readonly ConcurrentDictionary<string, CachedMetadata> _metadataCache = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, CachedPublicKey> _publicKeyCache = new(StringComparer.Ordinal);
|
||||
private bool _disposed;
|
||||
|
||||
public AwsKmsClient(IAwsKmsFacade facade, AwsKmsOptions options)
|
||||
{
|
||||
_facade = facade ?? throw new ArgumentNullException(nameof(facade));
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
_metadataCacheDuration = options.MetadataCacheDuration;
|
||||
_publicKeyCacheDuration = options.PublicKeyCacheDuration;
|
||||
}
|
||||
|
||||
public async Task<KmsSignResult> SignAsync(
|
||||
string keyId,
|
||||
string? keyVersion,
|
||||
ReadOnlyMemory<byte> 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 resource = ResolveResource(keyId, keyVersion);
|
||||
var result = await _facade.SignAsync(resource, digest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new KmsSignResult(
|
||||
keyId,
|
||||
string.IsNullOrWhiteSpace(result.VersionId) ? resource : result.VersionId,
|
||||
KmsAlgorithms.Es256,
|
||||
result.Signature);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(digest.AsSpan());
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> VerifyAsync(
|
||||
string keyId,
|
||||
string? keyVersion,
|
||||
ReadOnlyMemory<byte> data,
|
||||
ReadOnlyMemory<byte> signature,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
if (data.IsEmpty || signature.IsEmpty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var digest = ComputeSha256(data);
|
||||
try
|
||||
{
|
||||
var resource = ResolveResource(keyId, keyVersion);
|
||||
return await _facade.VerifyAsync(resource, digest, signature, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(digest.AsSpan());
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<KmsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
|
||||
var metadata = await GetCachedMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
|
||||
var publicKey = await GetCachedPublicKeyAsync(metadata.KeyId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var versionState = MapState(metadata.Status);
|
||||
var versionMetadata = ImmutableArray.Create(
|
||||
new KmsKeyVersionMetadata(
|
||||
publicKey.VersionId,
|
||||
versionState,
|
||||
metadata.CreatedAt,
|
||||
null,
|
||||
Convert.ToBase64String(publicKey.SubjectPublicKeyInfo),
|
||||
ResolveCurveName(publicKey.Curve)));
|
||||
|
||||
return new KmsKeyMetadata(
|
||||
metadata.KeyId,
|
||||
KmsAlgorithms.Es256,
|
||||
versionState,
|
||||
metadata.CreatedAt,
|
||||
versionMetadata);
|
||||
}
|
||||
|
||||
public async Task<KmsKeyMaterial> ExportAsync(
|
||||
string keyId,
|
||||
string? keyVersion,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
|
||||
var metadata = await GetCachedMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
|
||||
var resource = ResolveResource(metadata.KeyId, keyVersion);
|
||||
var publicKey = await GetCachedPublicKeyAsync(resource, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var ecdsa = ECDsa.Create();
|
||||
ecdsa.ImportSubjectPublicKeyInfo(publicKey.SubjectPublicKeyInfo, out _);
|
||||
var parameters = ecdsa.ExportParameters(false);
|
||||
|
||||
return new KmsKeyMaterial(
|
||||
metadata.KeyId,
|
||||
publicKey.VersionId,
|
||||
KmsAlgorithms.Es256,
|
||||
ResolveCurveName(publicKey.Curve),
|
||||
Array.Empty<byte>(),
|
||||
parameters.Q.X ?? throw new InvalidOperationException("Public key missing X coordinate."),
|
||||
parameters.Q.Y ?? throw new InvalidOperationException("Public key missing Y coordinate."),
|
||||
metadata.CreatedAt);
|
||||
}
|
||||
|
||||
public Task<KmsKeyMetadata> RotateAsync(string keyId, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("AWS KMS rotation must be orchestrated via AWS KMS policies or schedules.");
|
||||
|
||||
public Task RevokeAsync(string keyId, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("AWS KMS key revocation must be managed through AWS KMS APIs or console.");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_facade.Dispose();
|
||||
}
|
||||
|
||||
private async Task<AwsKeyMetadata> GetCachedMetadataAsync(string keyId, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (_metadataCache.TryGetValue(keyId, out var cached) && cached.ExpiresAt > now)
|
||||
{
|
||||
return cached.Metadata;
|
||||
}
|
||||
|
||||
var metadata = await _facade.GetMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
|
||||
var entry = new CachedMetadata(metadata, now.Add(_metadataCacheDuration));
|
||||
_metadataCache[keyId] = entry;
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private async Task<AwsPublicKeyMaterial> GetCachedPublicKeyAsync(string resource, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (_publicKeyCache.TryGetValue(resource, out var cached) && cached.ExpiresAt > now)
|
||||
{
|
||||
return cached.Material;
|
||||
}
|
||||
|
||||
var material = await _facade.GetPublicKeyAsync(resource, cancellationToken).ConfigureAwait(false);
|
||||
var entry = new CachedPublicKey(material, now.Add(_publicKeyCacheDuration));
|
||||
_publicKeyCache[resource] = entry;
|
||||
return material;
|
||||
}
|
||||
|
||||
private static byte[] ComputeSha256(ReadOnlyMemory<byte> 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 static string ResolveResource(string keyId, string? version)
|
||||
=> string.IsNullOrWhiteSpace(version) ? keyId : version;
|
||||
|
||||
private static string ResolveCurveName(string curve)
|
||||
{
|
||||
if (string.Equals(curve, "ECC_NIST_P256", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(curve, "P-256", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return JsonWebKeyECTypes.P256;
|
||||
}
|
||||
|
||||
if (string.Equals(curve, "ECC_NIST_P384", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(curve, "P-384", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return JsonWebKeyECTypes.P384;
|
||||
}
|
||||
|
||||
if (string.Equals(curve, "ECC_NIST_P521", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(curve, "P-521", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return JsonWebKeyECTypes.P521;
|
||||
}
|
||||
|
||||
if (string.Equals(curve, "SECP256K1", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(curve, "ECC_SECG_P256K1", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "secp256k1";
|
||||
}
|
||||
|
||||
return curve;
|
||||
}
|
||||
|
||||
private static KmsKeyState MapState(AwsKeyStatus status)
|
||||
=> status switch
|
||||
{
|
||||
AwsKeyStatus.Enabled => KmsKeyState.Active,
|
||||
AwsKeyStatus.PendingImport or AwsKeyStatus.PendingUpdate => KmsKeyState.PendingRotation,
|
||||
AwsKeyStatus.Disabled or AwsKeyStatus.PendingDeletion or AwsKeyStatus.Unavailable => KmsKeyState.Revoked,
|
||||
_ => KmsKeyState.Active,
|
||||
};
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(AwsKmsClient));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CachedMetadata(AwsKeyMetadata Metadata, DateTimeOffset ExpiresAt);
|
||||
|
||||
private sealed record CachedPublicKey(AwsPublicKeyMaterial Material, DateTimeOffset ExpiresAt);
|
||||
}
|
||||
186
src/__Libraries/StellaOps.Cryptography.Kms/AwsKmsFacade.cs
Normal file
186
src/__Libraries/StellaOps.Cryptography.Kms/AwsKmsFacade.cs
Normal file
@@ -0,0 +1,186 @@
|
||||
using System.IO;
|
||||
using Amazon;
|
||||
using Amazon.KeyManagementService;
|
||||
using Amazon.KeyManagementService.Model;
|
||||
|
||||
namespace StellaOps.Cryptography.Kms;
|
||||
|
||||
internal interface IAwsKmsFacade : IDisposable
|
||||
{
|
||||
Task<AwsSignResult> SignAsync(string keyResource, ReadOnlyMemory<byte> digest, CancellationToken cancellationToken);
|
||||
|
||||
Task<bool> VerifyAsync(string keyResource, ReadOnlyMemory<byte> digest, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken);
|
||||
|
||||
Task<AwsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken);
|
||||
|
||||
Task<AwsPublicKeyMaterial> GetPublicKeyAsync(string keyResource, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed record AwsSignResult(string KeyResource, string VersionId, byte[] Signature);
|
||||
|
||||
internal sealed record AwsKeyMetadata(string KeyId, string Arn, DateTimeOffset CreatedAt, AwsKeyStatus Status);
|
||||
|
||||
internal enum AwsKeyStatus
|
||||
{
|
||||
Unspecified = 0,
|
||||
Enabled = 1,
|
||||
Disabled = 2,
|
||||
PendingDeletion = 3,
|
||||
PendingImport = 4,
|
||||
PendingUpdate = 5,
|
||||
Unavailable = 6,
|
||||
}
|
||||
|
||||
internal sealed record AwsPublicKeyMaterial(string KeyId, string VersionId, string Curve, byte[] SubjectPublicKeyInfo);
|
||||
|
||||
internal sealed class AwsKmsFacade : IAwsKmsFacade
|
||||
{
|
||||
private readonly IAmazonKeyManagementService _client;
|
||||
private readonly bool _ownsClient;
|
||||
|
||||
public AwsKmsFacade(AwsKmsOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var config = new AmazonKeyManagementServiceConfig();
|
||||
if (!string.IsNullOrWhiteSpace(options.Region))
|
||||
{
|
||||
config.RegionEndpoint = RegionEndpoint.GetBySystemName(options.Region);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Endpoint))
|
||||
{
|
||||
config.ServiceURL = options.Endpoint;
|
||||
}
|
||||
|
||||
config.UseFIPSEndpoint = options.UseFipsEndpoint
|
||||
? UseFIPSEndpointState.Enabled
|
||||
: UseFIPSEndpointState.Disabled;
|
||||
|
||||
_client = new AmazonKeyManagementServiceClient(config);
|
||||
_ownsClient = true;
|
||||
}
|
||||
|
||||
public AwsKmsFacade(IAmazonKeyManagementService client)
|
||||
{
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
_ownsClient = false;
|
||||
}
|
||||
|
||||
public async Task<AwsSignResult> SignAsync(string keyResource, ReadOnlyMemory<byte> digest, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyResource);
|
||||
|
||||
using var messageStream = new MemoryStream(digest.ToArray(), writable: false);
|
||||
var request = new SignRequest
|
||||
{
|
||||
KeyId = keyResource,
|
||||
SigningAlgorithm = SigningAlgorithmSpec.ECDSA_SHA_256,
|
||||
MessageType = MessageType.DIGEST,
|
||||
Message = messageStream,
|
||||
};
|
||||
|
||||
var response = await _client.SignAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var keyId = response.KeyId ?? keyResource;
|
||||
return new AwsSignResult(keyId, keyId, response.Signature.ToArray());
|
||||
}
|
||||
|
||||
public async Task<bool> VerifyAsync(string keyResource, ReadOnlyMemory<byte> digest, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyResource);
|
||||
if (digest.IsEmpty || signature.IsEmpty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using var messageStream = new MemoryStream(digest.ToArray(), writable: false);
|
||||
var request = new VerifyRequest
|
||||
{
|
||||
KeyId = keyResource,
|
||||
SigningAlgorithm = SigningAlgorithmSpec.ECDSA_SHA_256,
|
||||
MessageType = MessageType.DIGEST,
|
||||
Message = messageStream,
|
||||
Signature = signature.ToArray(),
|
||||
};
|
||||
|
||||
var response = await _client.VerifyAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return response.SignatureValid;
|
||||
}
|
||||
|
||||
public async Task<AwsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
|
||||
var response = await _client.DescribeKeyAsync(new DescribeKeyRequest
|
||||
{
|
||||
KeyId = keyId,
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var metadata = response.KeyMetadata ?? throw new InvalidOperationException($"Key '{keyId}' was not found.");
|
||||
var createdAt = metadata.CreationDate?.ToUniversalTime() ?? DateTimeOffset.UtcNow;
|
||||
|
||||
return new AwsKeyMetadata(
|
||||
metadata.KeyId ?? keyId,
|
||||
metadata.Arn ?? metadata.KeyId ?? keyId,
|
||||
createdAt,
|
||||
MapStatus(metadata.KeyState));
|
||||
}
|
||||
|
||||
public async Task<AwsPublicKeyMaterial> GetPublicKeyAsync(string keyResource, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyResource);
|
||||
|
||||
var response = await _client.GetPublicKeyAsync(new GetPublicKeyRequest
|
||||
{
|
||||
KeyId = keyResource,
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyId = response.KeyId ?? keyResource;
|
||||
var versionId = response.KeyId ?? keyResource;
|
||||
var curve = ResolveCurve(response);
|
||||
|
||||
return new AwsPublicKeyMaterial(keyId, versionId, curve, response.PublicKey.ToArray());
|
||||
}
|
||||
|
||||
private static AwsKeyStatus MapStatus(KeyState? state)
|
||||
=> state switch
|
||||
{
|
||||
KeyState.Enabled => AwsKeyStatus.Enabled,
|
||||
KeyState.Disabled => AwsKeyStatus.Disabled,
|
||||
KeyState.PendingDeletion => AwsKeyStatus.PendingDeletion,
|
||||
KeyState.PendingImport => AwsKeyStatus.PendingImport,
|
||||
KeyState.PendingUpdate => AwsKeyStatus.PendingUpdate,
|
||||
KeyState.Unavailable => AwsKeyStatus.Unavailable,
|
||||
_ => AwsKeyStatus.Unspecified,
|
||||
};
|
||||
|
||||
private static string ResolveCurve(GetPublicKeyResponse response)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(response.CustomerMasterKeySpec))
|
||||
{
|
||||
return response.CustomerMasterKeySpec;
|
||||
}
|
||||
|
||||
if (response.KeySpec is not null)
|
||||
{
|
||||
return response.KeySpec.Value switch
|
||||
{
|
||||
KeySpec.ECC_NIST_P256 => "P-256",
|
||||
KeySpec.ECC_SECG_P256K1 => "secp256k1",
|
||||
KeySpec.ECC_NIST_P384 => "P-384",
|
||||
KeySpec.ECC_NIST_P521 => "P-521",
|
||||
_ => response.KeySpec.Value.ToString(),
|
||||
};
|
||||
}
|
||||
|
||||
return "P-256";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsClient && _client is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
54
src/__Libraries/StellaOps.Cryptography.Kms/AwsKmsOptions.cs
Normal file
54
src/__Libraries/StellaOps.Cryptography.Kms/AwsKmsOptions.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace StellaOps.Cryptography.Kms;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the AWS KMS-backed <see cref="IKmsClient"/>.
|
||||
/// </summary>
|
||||
public sealed class AwsKmsOptions
|
||||
{
|
||||
private TimeSpan metadataCacheDuration = TimeSpan.FromMinutes(5);
|
||||
private TimeSpan publicKeyCacheDuration = TimeSpan.FromMinutes(10);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the AWS region identifier (e.g. <c>us-east-1</c>).
|
||||
/// </summary>
|
||||
public string Region { get; set; } = "us-east-1";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional custom service endpoint (useful for local stacks or VPC endpoints).
|
||||
/// </summary>
|
||||
public string? Endpoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to use the FIPS endpoint for AWS KMS.
|
||||
/// </summary>
|
||||
public bool UseFipsEndpoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the cache duration for key metadata lookups.
|
||||
/// </summary>
|
||||
public TimeSpan MetadataCacheDuration
|
||||
{
|
||||
get => metadataCacheDuration;
|
||||
set => metadataCacheDuration = EnsurePositive(value, TimeSpan.FromMinutes(5));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the cache duration for exported public key material.
|
||||
/// </summary>
|
||||
public TimeSpan PublicKeyCacheDuration
|
||||
{
|
||||
get => publicKeyCacheDuration;
|
||||
set => publicKeyCacheDuration = EnsurePositive(value, TimeSpan.FromMinutes(10));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional factory that can provide a custom AWS facade. Primarily used for testing.
|
||||
/// </summary>
|
||||
public Func<IServiceProvider, IAwsKmsFacade>? FacadeFactory { get; set; }
|
||||
|
||||
private static TimeSpan EnsurePositive(TimeSpan value, TimeSpan @default)
|
||||
=> value <= TimeSpan.Zero ? @default : value;
|
||||
}
|
||||
|
||||
185
src/__Libraries/StellaOps.Cryptography.Kms/Fido2KmsClient.cs
Normal file
185
src/__Libraries/StellaOps.Cryptography.Kms/Fido2KmsClient.cs
Normal file
@@ -0,0 +1,185 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace StellaOps.Cryptography.Kms;
|
||||
|
||||
/// <summary>
|
||||
/// FIDO2-backed KMS client suitable for high-assurance interactive workflows.
|
||||
/// </summary>
|
||||
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<KmsSignResult> SignAsync(
|
||||
string keyId,
|
||||
string? keyVersion,
|
||||
ReadOnlyMemory<byte> 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<bool> VerifyAsync(
|
||||
string keyId,
|
||||
string? keyVersion,
|
||||
ReadOnlyMemory<byte> data,
|
||||
ReadOnlyMemory<byte> 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<KmsKeyMetadata> 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<KmsKeyMaterial> 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<byte>(),
|
||||
_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<KmsKeyMetadata> 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<byte> 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}'."),
|
||||
};
|
||||
}
|
||||
}
|
||||
44
src/__Libraries/StellaOps.Cryptography.Kms/Fido2Options.cs
Normal file
44
src/__Libraries/StellaOps.Cryptography.Kms/Fido2Options.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
namespace StellaOps.Cryptography.Kms;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for FIDO2-backed signing flows.
|
||||
/// </summary>
|
||||
public sealed class Fido2Options
|
||||
{
|
||||
private TimeSpan metadataCacheDuration = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the relying party identifier (rpId) used when registering the credential.
|
||||
/// </summary>
|
||||
public string RelyingPartyId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the credential identifier (Base64Url encoded string).
|
||||
/// </summary>
|
||||
public string CredentialId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the PEM-encoded public key associated with the credential.
|
||||
/// </summary>
|
||||
public string PublicKeyPem { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timestamp when the credential was provisioned.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the cache duration for metadata lookups.
|
||||
/// </summary>
|
||||
public TimeSpan MetadataCacheDuration
|
||||
{
|
||||
get => metadataCacheDuration;
|
||||
set => metadataCacheDuration = value <= TimeSpan.Zero ? TimeSpan.FromMinutes(5) : value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional authenticator factory hook (mainly for testing or custom integrations).
|
||||
/// </summary>
|
||||
public Func<IServiceProvider, IFido2Authenticator>? AuthenticatorFactory { get; set; }
|
||||
}
|
||||
|
||||
291
src/__Libraries/StellaOps.Cryptography.Kms/GcpKmsClient.cs
Normal file
291
src/__Libraries/StellaOps.Cryptography.Kms/GcpKmsClient.cs
Normal file
@@ -0,0 +1,291 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Google Cloud KMS implementation of <see cref="IKmsClient"/>.
|
||||
/// </summary>
|
||||
public sealed class GcpKmsClient : IKmsClient, IDisposable
|
||||
{
|
||||
private readonly IGcpKmsFacade _facade;
|
||||
private readonly TimeSpan _metadataCacheDuration;
|
||||
private readonly TimeSpan _publicKeyCacheDuration;
|
||||
|
||||
private readonly ConcurrentDictionary<string, CachedCryptoKey> _metadataCache = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, CachedPublicKey> _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<KmsSignResult> SignAsync(
|
||||
string keyId,
|
||||
string? keyVersion,
|
||||
ReadOnlyMemory<byte> 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<bool> VerifyAsync(
|
||||
string keyId,
|
||||
string? keyVersion,
|
||||
ReadOnlyMemory<byte> data,
|
||||
ReadOnlyMemory<byte> 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<KmsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
|
||||
var snapshot = await GetCachedMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var versions = ImmutableArray.CreateBuilder<KmsKeyVersionMetadata>(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<KmsKeyMaterial> 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<byte>(),
|
||||
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<KmsKeyMetadata> 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<CryptoKeySnapshot> 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<GcpPublicMaterial> 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<string> 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<byte> 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<GcpCryptoKeyVersionMetadata> Versions);
|
||||
|
||||
private sealed record GcpPublicMaterial(string VersionName, string Algorithm, byte[] SubjectPublicKeyInfo);
|
||||
}
|
||||
171
src/__Libraries/StellaOps.Cryptography.Kms/GcpKmsFacade.cs
Normal file
171
src/__Libraries/StellaOps.Cryptography.Kms/GcpKmsFacade.cs
Normal file
@@ -0,0 +1,171 @@
|
||||
using Google.Cloud.Kms.V1;
|
||||
using Google.Protobuf;
|
||||
|
||||
namespace StellaOps.Cryptography.Kms;
|
||||
|
||||
internal interface IGcpKmsFacade : IDisposable
|
||||
{
|
||||
Task<GcpSignResult> SignAsync(string versionName, ReadOnlyMemory<byte> digest, CancellationToken cancellationToken);
|
||||
|
||||
Task<GcpCryptoKeyMetadata> GetCryptoKeyMetadataAsync(string keyName, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<GcpCryptoKeyVersionMetadata>> ListKeyVersionsAsync(string keyName, CancellationToken cancellationToken);
|
||||
|
||||
Task<GcpPublicKeyMaterial> GetPublicKeyAsync(string versionName, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed record GcpSignResult(string VersionName, byte[] Signature);
|
||||
|
||||
internal sealed record GcpCryptoKeyMetadata(string KeyName, string? PrimaryVersionName, DateTimeOffset CreateTime);
|
||||
|
||||
internal enum GcpCryptoKeyVersionState
|
||||
{
|
||||
Unspecified = 0,
|
||||
PendingGeneration = 1,
|
||||
Enabled = 2,
|
||||
Disabled = 3,
|
||||
DestroyScheduled = 4,
|
||||
Destroyed = 5,
|
||||
PendingImport = 6,
|
||||
ImportFailed = 7,
|
||||
GenerationFailed = 8,
|
||||
}
|
||||
|
||||
internal sealed record GcpCryptoKeyVersionMetadata(
|
||||
string VersionName,
|
||||
GcpCryptoKeyVersionState State,
|
||||
DateTimeOffset CreateTime,
|
||||
DateTimeOffset? DestroyTime);
|
||||
|
||||
internal sealed record GcpPublicKeyMaterial(string VersionName, string Algorithm, string Pem);
|
||||
|
||||
internal sealed class GcpKmsFacade : IGcpKmsFacade
|
||||
{
|
||||
private readonly KeyManagementServiceClient _client;
|
||||
private readonly bool _ownsClient;
|
||||
|
||||
public GcpKmsFacade(GcpKmsOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
var builder = new KeyManagementServiceClientBuilder
|
||||
{
|
||||
Endpoint = string.IsNullOrWhiteSpace(options.Endpoint)
|
||||
? KeyManagementServiceClient.DefaultEndpoint.Host
|
||||
: options.Endpoint,
|
||||
};
|
||||
|
||||
_client = builder.Build();
|
||||
_ownsClient = true;
|
||||
}
|
||||
|
||||
public GcpKmsFacade(KeyManagementServiceClient client)
|
||||
{
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
_ownsClient = false;
|
||||
}
|
||||
|
||||
public async Task<GcpSignResult> SignAsync(string versionName, ReadOnlyMemory<byte> digest, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(versionName);
|
||||
|
||||
var response = await _client.AsymmetricSignAsync(new AsymmetricSignRequest
|
||||
{
|
||||
Name = versionName,
|
||||
Digest = new Digest
|
||||
{
|
||||
Sha256 = ByteString.CopyFrom(digest.ToArray()),
|
||||
},
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new GcpSignResult(response.Name ?? versionName, response.Signature.ToByteArray());
|
||||
}
|
||||
|
||||
public async Task<GcpCryptoKeyMetadata> GetCryptoKeyMetadataAsync(string keyName, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyName);
|
||||
|
||||
var response = await _client.GetCryptoKeyAsync(new GetCryptoKeyRequest
|
||||
{
|
||||
Name = keyName,
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new GcpCryptoKeyMetadata(
|
||||
response.Name,
|
||||
response.Primary?.Name,
|
||||
ToDateTimeOffsetOrUtcNow(response.CreateTime));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<GcpCryptoKeyVersionMetadata>> ListKeyVersionsAsync(string keyName, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyName);
|
||||
|
||||
var results = new List<GcpCryptoKeyVersionMetadata>();
|
||||
var request = new ListCryptoKeyVersionsRequest
|
||||
{
|
||||
Parent = keyName,
|
||||
};
|
||||
|
||||
await foreach (var version in _client.ListCryptoKeyVersionsAsync(request).WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(new GcpCryptoKeyVersionMetadata(
|
||||
version.Name,
|
||||
MapState(version.State),
|
||||
ToDateTimeOffsetOrUtcNow(version.CreateTime),
|
||||
version.DestroyTime is null ? null : ToDateTimeOffsetOrUtcNow(version.DestroyTime)));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<GcpPublicKeyMaterial> GetPublicKeyAsync(string versionName, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(versionName);
|
||||
|
||||
var response = await _client.GetPublicKeyAsync(new GetPublicKeyRequest
|
||||
{
|
||||
Name = versionName,
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new GcpPublicKeyMaterial(
|
||||
response.Name ?? versionName,
|
||||
response.Algorithm.ToString(),
|
||||
response.Pem);
|
||||
}
|
||||
|
||||
private static GcpCryptoKeyVersionState MapState(CryptoKeyVersion.Types.CryptoKeyVersionState state)
|
||||
=> state switch
|
||||
{
|
||||
CryptoKeyVersion.Types.CryptoKeyVersionState.Enabled => GcpCryptoKeyVersionState.Enabled,
|
||||
CryptoKeyVersion.Types.CryptoKeyVersionState.Disabled => GcpCryptoKeyVersionState.Disabled,
|
||||
CryptoKeyVersion.Types.CryptoKeyVersionState.DestroyScheduled => GcpCryptoKeyVersionState.DestroyScheduled,
|
||||
CryptoKeyVersion.Types.CryptoKeyVersionState.Destroyed => GcpCryptoKeyVersionState.Destroyed,
|
||||
CryptoKeyVersion.Types.CryptoKeyVersionState.PendingGeneration => GcpCryptoKeyVersionState.PendingGeneration,
|
||||
CryptoKeyVersion.Types.CryptoKeyVersionState.PendingImport => GcpCryptoKeyVersionState.PendingImport,
|
||||
CryptoKeyVersion.Types.CryptoKeyVersionState.ImportFailed => GcpCryptoKeyVersionState.ImportFailed,
|
||||
CryptoKeyVersion.Types.CryptoKeyVersionState.GenerationFailed => GcpCryptoKeyVersionState.GenerationFailed,
|
||||
_ => GcpCryptoKeyVersionState.Unspecified,
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsClient)
|
||||
{
|
||||
_client.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTimeOffset ToDateTimeOffsetOrUtcNow(Timestamp? timestamp)
|
||||
{
|
||||
if (timestamp is null)
|
||||
{
|
||||
return DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
if (timestamp.Seconds == 0 && timestamp.Nanos == 0)
|
||||
{
|
||||
return DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
return timestamp.ToDateTimeOffset();
|
||||
}
|
||||
}
|
||||
42
src/__Libraries/StellaOps.Cryptography.Kms/GcpKmsOptions.cs
Normal file
42
src/__Libraries/StellaOps.Cryptography.Kms/GcpKmsOptions.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
namespace StellaOps.Cryptography.Kms;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the Google Cloud KMS-backed <see cref="IKmsClient"/>.
|
||||
/// </summary>
|
||||
public sealed class GcpKmsOptions
|
||||
{
|
||||
private TimeSpan metadataCacheDuration = TimeSpan.FromMinutes(5);
|
||||
private TimeSpan publicKeyCacheDuration = TimeSpan.FromMinutes(10);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the service endpoint (default: <c>kms.googleapis.com</c>).
|
||||
/// </summary>
|
||||
public string Endpoint { get; set; } = "kms.googleapis.com";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the cache duration for crypto key metadata lookups.
|
||||
/// </summary>
|
||||
public TimeSpan MetadataCacheDuration
|
||||
{
|
||||
get => metadataCacheDuration;
|
||||
set => metadataCacheDuration = EnsurePositive(value, TimeSpan.FromMinutes(5));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the cache duration for exported public key material.
|
||||
/// </summary>
|
||||
public TimeSpan PublicKeyCacheDuration
|
||||
{
|
||||
get => publicKeyCacheDuration;
|
||||
set => publicKeyCacheDuration = EnsurePositive(value, TimeSpan.FromMinutes(10));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional factory that can construct a custom GCP facade (primarily used for testing).
|
||||
/// </summary>
|
||||
public Func<IServiceProvider, IGcpKmsFacade>? FacadeFactory { get; set; }
|
||||
|
||||
private static TimeSpan EnsurePositive(TimeSpan value, TimeSpan @default)
|
||||
=> value <= TimeSpan.Zero ? @default : value;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.Cryptography.Kms;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a FIDO2 authenticator capable of producing signatures over digests.
|
||||
/// </summary>
|
||||
public interface IFido2Authenticator
|
||||
{
|
||||
/// <summary>
|
||||
/// Performs a high-assurance signing operation using the configured FIDO2 credential.
|
||||
/// </summary>
|
||||
/// <param name="credentialId">Credential identifier as configured in the relying party.</param>
|
||||
/// <param name="digest">Digest of the payload (typically SHA-256) to sign.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Signature bytes.</returns>
|
||||
Task<byte[]> SignAsync(string credentialId, ReadOnlyMemory<byte> digest, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Cryptography.Kms.Tests")]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
@@ -84,28 +85,58 @@ public sealed class KmsCryptoProvider : ICryptoProvider
|
||||
foreach (var registration in _registrations.Values)
|
||||
{
|
||||
var material = _kmsClient.ExportAsync(registration.KeyId, registration.VersionId).GetAwaiter().GetResult();
|
||||
var parameters = new ECParameters
|
||||
{
|
||||
Curve = ECCurve.NamedCurves.nistP256,
|
||||
D = material.D,
|
||||
Q = new ECPoint
|
||||
{
|
||||
X = material.Qx,
|
||||
Y = material.Qy,
|
||||
},
|
||||
};
|
||||
|
||||
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[KmsMetadataKeys.Version] = material.VersionId
|
||||
};
|
||||
|
||||
list.Add(new CryptoSigningKey(
|
||||
new CryptoKeyReference(material.KeyId, Name),
|
||||
material.Algorithm,
|
||||
in parameters,
|
||||
material.CreatedAt,
|
||||
metadata: metadata));
|
||||
var reference = new CryptoKeyReference(material.KeyId, Name);
|
||||
CryptoSigningKey signingKey;
|
||||
|
||||
if (material.D.Length == 0)
|
||||
{
|
||||
// Remote KMS keys may withhold private scalars; represent them as raw keys using public coordinates.
|
||||
var privateHandle = Encoding.UTF8.GetBytes(string.IsNullOrWhiteSpace(material.VersionId) ? material.KeyId : material.VersionId);
|
||||
if (privateHandle.Length == 0)
|
||||
{
|
||||
privateHandle = material.Qx.Length > 0
|
||||
? material.Qx
|
||||
: material.Qy.Length > 0
|
||||
? material.Qy
|
||||
: throw new InvalidOperationException($"KMS key '{material.KeyId}' does not expose public coordinates.");
|
||||
}
|
||||
|
||||
var publicKey = CombineCoordinates(material.Qx, material.Qy);
|
||||
signingKey = new CryptoSigningKey(
|
||||
reference,
|
||||
material.Algorithm,
|
||||
privateHandle,
|
||||
material.CreatedAt,
|
||||
metadata: metadata,
|
||||
publicKey: publicKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
var parameters = new ECParameters
|
||||
{
|
||||
Curve = ECCurve.NamedCurves.nistP256,
|
||||
D = material.D,
|
||||
Q = new ECPoint
|
||||
{
|
||||
X = material.Qx,
|
||||
Y = material.Qy,
|
||||
},
|
||||
};
|
||||
|
||||
signingKey = new CryptoSigningKey(
|
||||
reference,
|
||||
material.Algorithm,
|
||||
in parameters,
|
||||
material.CreatedAt,
|
||||
metadata: metadata);
|
||||
}
|
||||
|
||||
list.Add(signingKey);
|
||||
}
|
||||
|
||||
return list;
|
||||
@@ -115,6 +146,27 @@ public sealed class KmsCryptoProvider : ICryptoProvider
|
||||
{
|
||||
public const string Version = "kms.version";
|
||||
}
|
||||
|
||||
private static byte[] CombineCoordinates(byte[] qx, byte[] qy)
|
||||
{
|
||||
if (qx.Length == 0 && qy.Length == 0)
|
||||
{
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
|
||||
var buffer = new byte[qx.Length + qy.Length];
|
||||
if (qx.Length > 0)
|
||||
{
|
||||
Buffer.BlockCopy(qx, 0, buffer, 0, qx.Length);
|
||||
}
|
||||
|
||||
if (qy.Length > 0)
|
||||
{
|
||||
Buffer.BlockCopy(qy, 0, buffer, qx.Length, qy.Length);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record KmsSigningRegistration(string KeyId, string VersionId, string Algorithm);
|
||||
|
||||
282
src/__Libraries/StellaOps.Cryptography.Kms/Pkcs11Facade.cs
Normal file
282
src/__Libraries/StellaOps.Cryptography.Kms/Pkcs11Facade.cs
Normal file
@@ -0,0 +1,282 @@
|
||||
using Net.Pkcs11Interop.Common;
|
||||
using Net.Pkcs11Interop.HighLevelAPI;
|
||||
using Net.Pkcs11Interop.HighLevelAPI.MechanismParams;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Formats.Asn1;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Cryptography.Kms;
|
||||
|
||||
internal interface IPkcs11Facade : IDisposable
|
||||
{
|
||||
Task<Pkcs11KeyDescriptor> GetKeyAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task<Pkcs11PublicKeyMaterial> GetPublicKeyAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task<byte[]> SignDigestAsync(ReadOnlyMemory<byte> digest, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed record Pkcs11KeyDescriptor(
|
||||
string KeyId,
|
||||
string? Label,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
internal sealed record Pkcs11PublicKeyMaterial(
|
||||
string KeyId,
|
||||
string Curve,
|
||||
byte[] Qx,
|
||||
byte[] Qy);
|
||||
|
||||
internal sealed class Pkcs11InteropFacade : IPkcs11Facade
|
||||
{
|
||||
private readonly Pkcs11Options _options;
|
||||
private readonly Pkcs11 _library;
|
||||
private readonly Slot _slot;
|
||||
private readonly ConcurrentDictionary<string, ObjectAttribute[]> _attributeCache = new(StringComparer.Ordinal);
|
||||
|
||||
public Pkcs11InteropFacade(Pkcs11Options options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
if (string.IsNullOrWhiteSpace(_options.LibraryPath))
|
||||
{
|
||||
throw new ArgumentException("PKCS#11 library path must be provided.", nameof(options));
|
||||
}
|
||||
|
||||
_library = new Pkcs11(_options.LibraryPath, AppType.MultiThreaded);
|
||||
_slot = ResolveSlot(_library, _options)
|
||||
?? throw new InvalidOperationException("Could not resolve PKCS#11 slot.");
|
||||
}
|
||||
|
||||
public async Task<Pkcs11KeyDescriptor> GetKeyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var context = await OpenSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
var session = context.Session;
|
||||
var privateHandle = FindKey(session, CKO.CKO_PRIVATE_KEY, _options.PrivateKeyLabel);
|
||||
if (privateHandle is null)
|
||||
{
|
||||
throw new InvalidOperationException("PKCS#11 private key not found.");
|
||||
}
|
||||
|
||||
var labelAttr = GetAttribute(session, privateHandle.Value, CKA.CKA_LABEL);
|
||||
var label = labelAttr?.GetValueAsString();
|
||||
|
||||
return new Pkcs11KeyDescriptor(
|
||||
KeyId: label ?? privateHandle.Value.ObjectId.ToString(),
|
||||
Label: label,
|
||||
CreatedAt: DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
public async Task<Pkcs11PublicKeyMaterial> GetPublicKeyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var context = await OpenSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
var session = context.Session;
|
||||
var publicHandle = FindKey(session, CKO.CKO_PUBLIC_KEY, _options.PublicKeyLabel ?? _options.PrivateKeyLabel);
|
||||
if (publicHandle is null)
|
||||
{
|
||||
throw new InvalidOperationException("PKCS#11 public key not found.");
|
||||
}
|
||||
|
||||
var pointAttr = GetAttribute(session, publicHandle.Value, CKA.CKA_EC_POINT)
|
||||
?? throw new InvalidOperationException("Public key missing EC point.");
|
||||
var paramsAttr = GetAttribute(session, publicHandle.Value, CKA.CKA_EC_PARAMS)
|
||||
?? throw new InvalidOperationException("Public key missing EC parameters.");
|
||||
|
||||
var ecPoint = ExtractEcPoint(pointAttr.GetValueAsByteArray());
|
||||
var (curve, coordinateSize) = DecodeCurve(paramsAttr.GetValueAsByteArray());
|
||||
|
||||
if (ecPoint.Length != 1 + (coordinateSize * 2) || ecPoint[0] != 0x04)
|
||||
{
|
||||
throw new InvalidOperationException("Unsupported EC point format.");
|
||||
}
|
||||
|
||||
var qx = ecPoint.AsSpan(1, coordinateSize).ToArray();
|
||||
var qy = ecPoint.AsSpan(1 + coordinateSize, coordinateSize).ToArray();
|
||||
|
||||
var keyId = GetAttribute(session, publicHandle.Value, CKA.CKA_LABEL)?.GetValueAsString()
|
||||
?? publicHandle.Value.ObjectId.ToString();
|
||||
|
||||
return new Pkcs11PublicKeyMaterial(
|
||||
keyId,
|
||||
curve,
|
||||
qx,
|
||||
qy);
|
||||
}
|
||||
|
||||
public async Task<byte[]> SignDigestAsync(ReadOnlyMemory<byte> digest, CancellationToken cancellationToken)
|
||||
{
|
||||
using var context = await OpenSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
var session = context.Session;
|
||||
var privateHandle = FindKey(session, CKO.CKO_PRIVATE_KEY, _options.PrivateKeyLabel)
|
||||
?? throw new InvalidOperationException("PKCS#11 private key not found.");
|
||||
|
||||
var mechanism = new Mechanism(_options.MechanismId);
|
||||
return session.Sign(mechanism, privateHandle.Value, digest.ToArray());
|
||||
}
|
||||
|
||||
private async Task<SessionContext> OpenSessionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var session = _slot.OpenSession(SessionType.ReadOnly);
|
||||
|
||||
var loggedIn = false;
|
||||
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.UserPin))
|
||||
{
|
||||
session.Login(CKU.CKU_USER, _options.UserPin);
|
||||
loggedIn = true;
|
||||
}
|
||||
|
||||
return new SessionContext(session, loggedIn);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (loggedIn)
|
||||
{
|
||||
try { session.Logout(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
session.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private ObjectHandle? FindKey(ISession session, CKO objectClass, string? label)
|
||||
{
|
||||
var template = new List<ObjectAttribute>
|
||||
{
|
||||
new(CKA.CKA_CLASS, (uint)objectClass)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(label))
|
||||
{
|
||||
template.Add(new ObjectAttribute(CKA.CKA_LABEL, label));
|
||||
}
|
||||
|
||||
var handles = session.FindAllObjects(template);
|
||||
return handles.FirstOrDefault();
|
||||
}
|
||||
|
||||
private ObjectAttribute? GetAttribute(ISession session, ObjectHandle handle, CKA type)
|
||||
{
|
||||
var cacheKey = $"{handle.ObjectId}:{(uint)type}";
|
||||
if (_attributeCache.TryGetValue(cacheKey, out var cached))
|
||||
{
|
||||
return cached.FirstOrDefault();
|
||||
}
|
||||
|
||||
var attributes = session.GetAttributeValue(handle, new List<CKA> { type })
|
||||
?.Select(attr => new ObjectAttribute(attr.Type, attr.GetValueAsByteArray()))
|
||||
.ToArray() ?? Array.Empty<ObjectAttribute>();
|
||||
|
||||
if (attributes.Length > 0)
|
||||
{
|
||||
_attributeCache[cacheKey] = attributes;
|
||||
return attributes[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Slot? ResolveSlot(Pkcs11 pkcs11, Pkcs11Options options)
|
||||
{
|
||||
var slots = pkcs11.GetSlotList(SlotsType.WithTokenPresent);
|
||||
if (slots.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.SlotId))
|
||||
{
|
||||
return slots.FirstOrDefault(slot => string.Equals(slot.SlotId.ToString(), options.SlotId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.TokenLabel))
|
||||
{
|
||||
return slots.FirstOrDefault(slot =>
|
||||
{
|
||||
var info = slot.GetTokenInfo();
|
||||
return string.Equals(info.Label?.Trim(), options.TokenLabel.Trim(), StringComparison.Ordinal);
|
||||
});
|
||||
}
|
||||
|
||||
return slots[0];
|
||||
}
|
||||
|
||||
private static byte[] ExtractEcPoint(byte[] derEncoded)
|
||||
{
|
||||
var reader = new AsnReader(derEncoded, AsnEncodingRules.DER);
|
||||
var point = reader.ReadOctetString();
|
||||
reader.ThrowIfNotEmpty();
|
||||
return point;
|
||||
}
|
||||
|
||||
private static (string CurveName, int CoordinateSize) DecodeCurve(byte[] ecParamsDer)
|
||||
{
|
||||
var reader = new AsnReader(ecParamsDer, AsnEncodingRules.DER);
|
||||
var oid = reader.ReadObjectIdentifier();
|
||||
reader.ThrowIfNotEmpty();
|
||||
|
||||
var curve = 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 EC curve OID '{oid}'."),
|
||||
};
|
||||
|
||||
var coordinateSize = curve switch
|
||||
{
|
||||
JsonWebKeyECTypes.P256 => 32,
|
||||
JsonWebKeyECTypes.P384 => 48,
|
||||
JsonWebKeyECTypes.P521 => 66,
|
||||
_ => throw new InvalidOperationException($"Unsupported EC curve '{curve}'."),
|
||||
};
|
||||
|
||||
return (curve, coordinateSize);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_library.Dispose();
|
||||
}
|
||||
|
||||
private sealed class SessionContext : System.IDisposable
|
||||
{
|
||||
private readonly ISession _session;
|
||||
private readonly bool _logoutOnDispose;
|
||||
private bool _disposed;
|
||||
|
||||
public SessionContext(ISession session, bool logoutOnDispose)
|
||||
{
|
||||
_session = session ?? throw new System.ArgumentNullException(nameof(session));
|
||||
_logoutOnDispose = logoutOnDispose;
|
||||
}
|
||||
|
||||
public ISession Session => _session;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_logoutOnDispose)
|
||||
{
|
||||
try
|
||||
{
|
||||
_session.Logout();
|
||||
}
|
||||
catch
|
||||
{
|
||||
# ignore logout failures
|
||||
}
|
||||
}
|
||||
|
||||
_session.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
228
src/__Libraries/StellaOps.Cryptography.Kms/Pkcs11KmsClient.cs
Normal file
228
src/__Libraries/StellaOps.Cryptography.Kms/Pkcs11KmsClient.cs
Normal file
@@ -0,0 +1,228 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Cryptography.Kms;
|
||||
|
||||
/// <summary>
|
||||
/// PKCS#11-backed implementation of <see cref="IKmsClient"/>.
|
||||
/// </summary>
|
||||
public sealed class Pkcs11KmsClient : IKmsClient
|
||||
{
|
||||
private readonly IPkcs11Facade _facade;
|
||||
private readonly TimeSpan _metadataCacheDuration;
|
||||
private readonly TimeSpan _publicKeyCacheDuration;
|
||||
|
||||
private readonly ConcurrentDictionary<string, CachedMetadata> _metadataCache = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, CachedPublicKey> _publicKeyCache = new(StringComparer.Ordinal);
|
||||
private bool _disposed;
|
||||
|
||||
public Pkcs11KmsClient(IPkcs11Facade facade, Pkcs11Options options)
|
||||
{
|
||||
_facade = facade ?? throw new ArgumentNullException(nameof(facade));
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
_metadataCacheDuration = options.MetadataCacheDuration;
|
||||
_publicKeyCacheDuration = options.PublicKeyCacheDuration;
|
||||
}
|
||||
|
||||
public async Task<KmsSignResult> SignAsync(
|
||||
string keyId,
|
||||
string? keyVersion,
|
||||
ReadOnlyMemory<byte> 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 descriptor = await GetCachedMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
|
||||
var signature = await _facade.SignDigestAsync(digest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new KmsSignResult(
|
||||
descriptor.Descriptor.KeyId,
|
||||
descriptor.Descriptor.KeyId,
|
||||
KmsAlgorithms.Es256,
|
||||
signature);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(digest.AsSpan());
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> VerifyAsync(
|
||||
string keyId,
|
||||
string? keyVersion,
|
||||
ReadOnlyMemory<byte> data,
|
||||
ReadOnlyMemory<byte> signature,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
if (data.IsEmpty || signature.IsEmpty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var digest = ComputeSha256(data);
|
||||
try
|
||||
{
|
||||
var publicMaterial = await GetCachedPublicKeyAsync(keyId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var ecdsa = ECDsa.Create(new ECParameters
|
||||
{
|
||||
Curve = ResolveCurve(publicMaterial.Material.Curve),
|
||||
Q =
|
||||
{
|
||||
X = publicMaterial.Material.Qx,
|
||||
Y = publicMaterial.Material.Qy,
|
||||
},
|
||||
});
|
||||
|
||||
return ecdsa.VerifyHash(digest, signature.ToArray());
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(digest.AsSpan());
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<KmsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
|
||||
var descriptor = await GetCachedMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
|
||||
var publicMaterial = await GetCachedPublicKeyAsync(keyId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var ecdsa = ECDsa.Create(new ECParameters
|
||||
{
|
||||
Curve = ResolveCurve(publicMaterial.Material.Curve),
|
||||
Q =
|
||||
{
|
||||
X = publicMaterial.Material.Qx,
|
||||
Y = publicMaterial.Material.Qy,
|
||||
},
|
||||
});
|
||||
|
||||
var subjectInfo = Convert.ToBase64String(ecdsa.ExportSubjectPublicKeyInfo());
|
||||
|
||||
var version = new KmsKeyVersionMetadata(
|
||||
descriptor.Descriptor.KeyId,
|
||||
KmsKeyState.Active,
|
||||
descriptor.Descriptor.CreatedAt,
|
||||
null,
|
||||
subjectInfo,
|
||||
publicMaterial.Material.Curve);
|
||||
|
||||
return new KmsKeyMetadata(
|
||||
descriptor.Descriptor.KeyId,
|
||||
KmsAlgorithms.Es256,
|
||||
KmsKeyState.Active,
|
||||
descriptor.Descriptor.CreatedAt,
|
||||
ImmutableArray.Create(version));
|
||||
}
|
||||
|
||||
public async Task<KmsKeyMaterial> ExportAsync(string keyId, string? keyVersion, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
|
||||
var descriptor = await GetCachedMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
|
||||
var publicMaterial = await GetCachedPublicKeyAsync(keyId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new KmsKeyMaterial(
|
||||
descriptor.Descriptor.KeyId,
|
||||
descriptor.Descriptor.KeyId,
|
||||
KmsAlgorithms.Es256,
|
||||
publicMaterial.Material.Curve,
|
||||
Array.Empty<byte>(),
|
||||
publicMaterial.Material.Qx,
|
||||
publicMaterial.Material.Qy,
|
||||
descriptor.Descriptor.CreatedAt);
|
||||
}
|
||||
|
||||
public Task<KmsKeyMetadata> RotateAsync(string keyId, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("PKCS#11 rotation requires HSM administrative tooling.");
|
||||
|
||||
public Task RevokeAsync(string keyId, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("PKCS#11 revocation must be handled by HSM policies.");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_facade.Dispose();
|
||||
}
|
||||
|
||||
private async Task<CachedMetadata> GetCachedMetadataAsync(string keyId, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (_metadataCache.TryGetValue(keyId, out var cached) && cached.ExpiresAt > now)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var descriptor = await _facade.GetKeyAsync(cancellationToken).ConfigureAwait(false);
|
||||
var entry = new CachedMetadata(descriptor, now.Add(_metadataCacheDuration));
|
||||
_metadataCache[keyId] = entry;
|
||||
return entry;
|
||||
}
|
||||
|
||||
private async Task<CachedPublicKey> GetCachedPublicKeyAsync(string keyId, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (_publicKeyCache.TryGetValue(keyId, out var cached) && cached.ExpiresAt > now)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var material = await _facade.GetPublicKeyAsync(cancellationToken).ConfigureAwait(false);
|
||||
var entry = new CachedPublicKey(material, now.Add(_publicKeyCacheDuration));
|
||||
_publicKeyCache[keyId] = entry;
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static byte[] ComputeSha256(ReadOnlyMemory<byte> 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 static ECCurve ResolveCurve(string curve)
|
||||
=> curve switch
|
||||
{
|
||||
JsonWebKeyECTypes.P256 => ECCurve.NamedCurves.nistP256,
|
||||
JsonWebKeyECTypes.P384 => ECCurve.NamedCurves.nistP384,
|
||||
JsonWebKeyECTypes.P521 => ECCurve.NamedCurves.nistP521,
|
||||
_ => throw new InvalidOperationException($"Unsupported EC curve '{curve}'."),
|
||||
};
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(Pkcs11KmsClient));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CachedMetadata(Pkcs11KeyDescriptor Descriptor, DateTimeOffset ExpiresAt);
|
||||
|
||||
private sealed record CachedPublicKey(Pkcs11PublicKeyMaterial Material, DateTimeOffset ExpiresAt);
|
||||
}
|
||||
72
src/__Libraries/StellaOps.Cryptography.Kms/Pkcs11Options.cs
Normal file
72
src/__Libraries/StellaOps.Cryptography.Kms/Pkcs11Options.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
namespace StellaOps.Cryptography.Kms;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for PKCS#11-based HSM integrations.
|
||||
/// </summary>
|
||||
public sealed class Pkcs11Options
|
||||
{
|
||||
private TimeSpan metadataCacheDuration = TimeSpan.FromMinutes(5);
|
||||
private TimeSpan publicKeyCacheDuration = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the native PKCS#11 library path.
|
||||
/// </summary>
|
||||
public string LibraryPath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional slot identifier (decimal or hexadecimal). Mutually exclusive with <see cref="TokenLabel"/>.
|
||||
/// </summary>
|
||||
public string? SlotId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional token label to select the target slot. Mutually exclusive with <see cref="SlotId"/>.
|
||||
/// </summary>
|
||||
public string? TokenLabel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the PKCS#11 private key label.
|
||||
/// </summary>
|
||||
public string? PrivateKeyLabel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the PKCS#11 public key label (optional; falls back to <see cref="PrivateKeyLabel"/>).
|
||||
/// </summary>
|
||||
public string? PublicKeyLabel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the PIN used for user authentication.
|
||||
/// </summary>
|
||||
public string? UserPin { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional PKCS#11 mechanism identifier (default: CKM_ECDSA).
|
||||
/// </summary>
|
||||
public uint MechanismId { get; set; } = (uint)Net.Pkcs11Interop.Common.CKM.CKM_ECDSA;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the cache duration for metadata requests (slot/key info).
|
||||
/// </summary>
|
||||
public TimeSpan MetadataCacheDuration
|
||||
{
|
||||
get => metadataCacheDuration;
|
||||
set => metadataCacheDuration = EnsurePositive(value, TimeSpan.FromMinutes(5));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the cache duration for public key material.
|
||||
/// </summary>
|
||||
public TimeSpan PublicKeyCacheDuration
|
||||
{
|
||||
get => publicKeyCacheDuration;
|
||||
set => publicKeyCacheDuration = EnsurePositive(value, TimeSpan.FromMinutes(5));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional factory for advanced facade injection (testing, custom providers).
|
||||
/// </summary>
|
||||
public Func<IServiceProvider, IPkcs11Facade>? FacadeFactory { get; set; }
|
||||
|
||||
private static TimeSpan EnsurePositive(TimeSpan value, TimeSpan fallback)
|
||||
=> value <= TimeSpan.Zero ? fallback : value;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@ public static class ServiceCollectionExtensions
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
services.RemoveAll<IKmsClient>();
|
||||
services.RemoveAll<IAwsKmsFacade>();
|
||||
services.RemoveAll<IGcpKmsFacade>();
|
||||
services.RemoveAll<IPkcs11Facade>();
|
||||
|
||||
services.Configure(configure);
|
||||
|
||||
services.TryAddSingleton<IKmsClient>(sp =>
|
||||
@@ -29,4 +34,134 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddAwsKms(
|
||||
this IServiceCollection services,
|
||||
Action<AwsKmsOptions> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
services.RemoveAll<IKmsClient>();
|
||||
services.RemoveAll<IAwsKmsFacade>();
|
||||
services.RemoveAll<IGcpKmsFacade>();
|
||||
services.RemoveAll<IPkcs11Facade>();
|
||||
|
||||
services.Configure(configure);
|
||||
|
||||
services.AddSingleton<IAwsKmsFacade>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<AwsKmsOptions>>().Value ?? new AwsKmsOptions();
|
||||
return options.FacadeFactory?.Invoke(sp) ?? new AwsKmsFacade(options);
|
||||
});
|
||||
|
||||
services.AddSingleton<IKmsClient>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<AwsKmsOptions>>().Value ?? new AwsKmsOptions();
|
||||
var facade = sp.GetRequiredService<IAwsKmsFacade>();
|
||||
return new AwsKmsClient(facade, options);
|
||||
});
|
||||
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, KmsCryptoProvider>());
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddGcpKms(
|
||||
this IServiceCollection services,
|
||||
Action<GcpKmsOptions> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
services.RemoveAll<IKmsClient>();
|
||||
services.RemoveAll<IAwsKmsFacade>();
|
||||
services.RemoveAll<IGcpKmsFacade>();
|
||||
services.RemoveAll<IPkcs11Facade>();
|
||||
|
||||
services.Configure(configure);
|
||||
|
||||
services.AddSingleton<IGcpKmsFacade>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<GcpKmsOptions>>().Value ?? new GcpKmsOptions();
|
||||
return options.FacadeFactory?.Invoke(sp) ?? new GcpKmsFacade(options);
|
||||
});
|
||||
|
||||
services.AddSingleton<IKmsClient>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<GcpKmsOptions>>().Value ?? new GcpKmsOptions();
|
||||
var facade = sp.GetRequiredService<IGcpKmsFacade>();
|
||||
return new GcpKmsClient(facade, options);
|
||||
});
|
||||
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, KmsCryptoProvider>());
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddPkcs11Kms(
|
||||
this IServiceCollection services,
|
||||
Action<Pkcs11Options> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
services.RemoveAll<IKmsClient>();
|
||||
services.RemoveAll<IAwsKmsFacade>();
|
||||
services.RemoveAll<IGcpKmsFacade>();
|
||||
services.RemoveAll<IPkcs11Facade>();
|
||||
|
||||
services.Configure(configure);
|
||||
|
||||
services.AddSingleton<IPkcs11Facade>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<Pkcs11Options>>().Value ?? new Pkcs11Options();
|
||||
return options.FacadeFactory?.Invoke(sp) ?? new Pkcs11InteropFacade(options);
|
||||
});
|
||||
|
||||
services.AddSingleton<IKmsClient>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<Pkcs11Options>>().Value ?? new Pkcs11Options();
|
||||
var facade = sp.GetRequiredService<IPkcs11Facade>();
|
||||
return new Pkcs11KmsClient(facade, options);
|
||||
});
|
||||
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, KmsCryptoProvider>());
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddFido2Kms(
|
||||
this IServiceCollection services,
|
||||
Action<Fido2Options> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
services.RemoveAll<IKmsClient>();
|
||||
|
||||
services.Configure(configure);
|
||||
|
||||
services.TryAddSingleton<IFido2Authenticator>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<Fido2Options>>().Value ?? new Fido2Options();
|
||||
if (options.AuthenticatorFactory is null)
|
||||
{
|
||||
throw new InvalidOperationException("Fido2Options.AuthenticatorFactory must be provided or IFido2Authenticator registered separately.");
|
||||
}
|
||||
|
||||
return options.AuthenticatorFactory(sp);
|
||||
});
|
||||
|
||||
services.AddSingleton<IKmsClient>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<Fido2Options>>().Value ?? new Fido2Options();
|
||||
var authenticator = sp.GetRequiredService<IFido2Authenticator>();
|
||||
return new Fido2KmsClient(authenticator, options);
|
||||
});
|
||||
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, KmsCryptoProvider>());
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0" />
|
||||
<PackageReference Include="AWSSDK.KeyManagementService" Version="4.0.6" />
|
||||
<PackageReference Include="Google.Cloud.Kms.V1" Version="3.19.0" />
|
||||
<PackageReference Include="Pkcs11Interop" Version="5.3.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
|
||||
@@ -7,5 +7,7 @@
|
||||
## Sprint 73 – Cloud & HSM Integration
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| KMS-73-001 | TODO | KMS Guild | KMS-72-001 | Add cloud KMS driver (e.g., AWS KMS, GCP KMS) with signing and key metadata retrieval. | Cloud driver tested with mock; configuration documented; security review sign-off. |
|
||||
| KMS-73-002 | TODO | KMS Guild | KMS-72-001 | Implement PKCS#11/HSM driver plus FIDO2 signing support for high assurance workflows. | HSM/FIDO2 drivers tested with hardware stubs; error handling documented. |
|
||||
| KMS-73-001 | DONE (2025-11-03) | KMS Guild | KMS-72-001 | Add cloud KMS driver (e.g., AWS KMS, GCP KMS) with signing and key metadata retrieval. | Cloud driver tested with mock; configuration documented; security review sign-off. |
|
||||
> AWS/GCP facades implement digest-first signing, cache metadata/public keys (`AwsKmsOptions`, `GcpKmsOptions`), and surface non-exportable keys without private material; unit tests cover signing, verification, metadata, and export flows.
|
||||
| KMS-73-002 | DONE (2025-11-03) | KMS Guild | KMS-72-001 | Implement PKCS#11/HSM driver plus FIDO2 signing support for high assurance workflows. | HSM/FIDO2 drivers tested with hardware stubs; error handling documented. |
|
||||
> PKCS#11 facade/client pair added with deterministic digesting + caches, FIDO2 client honors authenticator factories, DI extensions published, signer docs refreshed, and xUnit fakes assert sign/verify/export flows.
|
||||
|
||||
@@ -13,4 +13,17 @@ public interface IIssuerDirectoryClient
|
||||
string issuerId,
|
||||
bool includeGlobal,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<IssuerTrustResponseModel> SetIssuerTrustAsync(
|
||||
string tenantId,
|
||||
string issuerId,
|
||||
decimal weight,
|
||||
string? reason,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
ValueTask DeleteIssuerTrustAsync(
|
||||
string tenantId,
|
||||
string issuerId,
|
||||
string? reason,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ internal sealed class IssuerDirectoryClient : IIssuerDirectoryClient
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
|
||||
|
||||
tenantId = tenantId.Trim();
|
||||
issuerId = issuerId.Trim();
|
||||
|
||||
var cacheKey = CacheKey("keys", tenantId, issuerId, includeGlobal.ToString(CultureInfo.InvariantCulture));
|
||||
if (_cache.TryGetValue(cacheKey, out IReadOnlyList<IssuerKeyModel>? cached) && cached is not null)
|
||||
{
|
||||
@@ -77,6 +80,9 @@ internal sealed class IssuerDirectoryClient : IIssuerDirectoryClient
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
|
||||
|
||||
tenantId = tenantId.Trim();
|
||||
issuerId = issuerId.Trim();
|
||||
|
||||
var cacheKey = CacheKey("trust", tenantId, issuerId, includeGlobal.ToString(CultureInfo.InvariantCulture));
|
||||
if (_cache.TryGetValue(cacheKey, out IssuerTrustResponseModel? cached) && cached is not null)
|
||||
{
|
||||
@@ -105,6 +111,84 @@ internal sealed class IssuerDirectoryClient : IIssuerDirectoryClient
|
||||
return payload;
|
||||
}
|
||||
|
||||
public async ValueTask<IssuerTrustResponseModel> SetIssuerTrustAsync(
|
||||
string tenantId,
|
||||
string issuerId,
|
||||
decimal weight,
|
||||
string? reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
|
||||
|
||||
var normalizedTenant = tenantId.Trim();
|
||||
var normalizedReason = string.IsNullOrWhiteSpace(reason) ? null : reason.Trim();
|
||||
var requestUri = $"issuer-directory/issuers/{Uri.EscapeDataString(issuerId)}/trust";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Put, requestUri)
|
||||
{
|
||||
Content = JsonContent.Create(new IssuerTrustSetRequestModel(weight, normalizedReason))
|
||||
};
|
||||
|
||||
request.Headers.TryAddWithoutValidation(_options.TenantHeader, normalizedTenant);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedReason))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(_options.AuditReasonHeader, normalizedReason);
|
||||
}
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Issuer Directory trust update failed for {IssuerId} (tenant={TenantId}) {StatusCode}",
|
||||
issuerId,
|
||||
normalizedTenant,
|
||||
response.StatusCode);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
InvalidateTrustCache(normalizedTenant, issuerId);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<IssuerTrustResponseModel>(cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false) ?? new IssuerTrustResponseModel(null, null, 0m);
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
public async ValueTask DeleteIssuerTrustAsync(
|
||||
string tenantId,
|
||||
string issuerId,
|
||||
string? reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
|
||||
|
||||
var normalizedTenant = tenantId.Trim();
|
||||
var normalizedReason = string.IsNullOrWhiteSpace(reason) ? null : reason.Trim();
|
||||
var requestUri = $"issuer-directory/issuers/{Uri.EscapeDataString(issuerId)}/trust";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Delete, requestUri);
|
||||
request.Headers.TryAddWithoutValidation(_options.TenantHeader, normalizedTenant);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedReason))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(_options.AuditReasonHeader, normalizedReason);
|
||||
}
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Issuer Directory trust delete failed for {IssuerId} (tenant={TenantId}) {StatusCode}",
|
||||
issuerId,
|
||||
normalizedTenant,
|
||||
response.StatusCode);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
InvalidateTrustCache(normalizedTenant, issuerId);
|
||||
}
|
||||
|
||||
private static string CacheKey(string prefix, params string[] parts)
|
||||
{
|
||||
if (parts is null || parts.Length == 0)
|
||||
@@ -117,4 +201,11 @@ internal sealed class IssuerDirectoryClient : IIssuerDirectoryClient
|
||||
Array.Copy(parts, 0, segments, 1, parts.Length);
|
||||
return string.Join('|', segments);
|
||||
}
|
||||
|
||||
private void InvalidateTrustCache(string tenantId, string issuerId)
|
||||
{
|
||||
_cache.Remove(CacheKey("trust", tenantId, issuerId, bool.FalseString));
|
||||
_cache.Remove(CacheKey("trust", tenantId, issuerId, bool.TrueString));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ public sealed class IssuerDirectoryClientOptions
|
||||
|
||||
public string TenantHeader { get; set; } = "X-StellaOps-Tenant";
|
||||
|
||||
public string AuditReasonHeader { get; set; } = "X-StellaOps-Reason";
|
||||
|
||||
public IssuerDirectoryCacheOptions Cache { get; set; } = new();
|
||||
|
||||
internal void Validate()
|
||||
@@ -33,6 +35,11 @@ public sealed class IssuerDirectoryClientOptions
|
||||
{
|
||||
throw new InvalidOperationException("IssuerDirectory tenant header must be configured.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(AuditReasonHeader))
|
||||
{
|
||||
throw new InvalidOperationException("IssuerDirectory audit reason header must be configured.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,3 +28,7 @@ public sealed record IssuerTrustResponseModel(
|
||||
[property: JsonPropertyName("tenantOverride")] IssuerTrustOverrideModel? TenantOverride,
|
||||
[property: JsonPropertyName("globalOverride")] IssuerTrustOverrideModel? GlobalOverride,
|
||||
[property: JsonPropertyName("effectiveWeight")] decimal EffectiveWeight);
|
||||
|
||||
public sealed record IssuerTrustSetRequestModel(
|
||||
[property: JsonPropertyName("weight")] decimal Weight,
|
||||
[property: JsonPropertyName("reason")] string? Reason);
|
||||
|
||||
20
src/__Libraries/StellaOps.Replay.Core/AGENTS.md
Normal file
20
src/__Libraries/StellaOps.Replay.Core/AGENTS.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# StellaOps.Replay.Core — Agent Charter
|
||||
|
||||
## Purpose
|
||||
Own shared replay domain types, canonicalisation helpers, bundle hashing utilities, and DSSE payload builders that power deterministic replay across Stella Ops services.
|
||||
|
||||
## Required Reading
|
||||
- `docs/replay/DETERMINISTIC_REPLAY.md`
|
||||
- `docs/replay/DEVS_GUIDE_REPLAY.md`
|
||||
- `docs/modules/platform/architecture-overview.md` (Replay CAS section once published)
|
||||
- `docs/data/replay_schema.md` (when created)
|
||||
|
||||
## Expectations
|
||||
1. Maintain deterministic behaviour (lexicographic ordering, canonical JSON, fixed encodings).
|
||||
2. Keep APIs offline-friendly; no network dependencies.
|
||||
3. Coordinate schema and bundle changes with Scanner, Evidence Locker, CLI, and Docs guilds.
|
||||
4. Update module `TASKS.md` statuses alongside `docs/implplan/SPRINT_185_replay_core.md`.
|
||||
|
||||
## Contacts
|
||||
- BE-Base Platform Guild (primary)
|
||||
- Docs Guild (for spec alignment)
|
||||
6
src/__Libraries/StellaOps.Replay.Core/TASKS.md
Normal file
6
src/__Libraries/StellaOps.Replay.Core/TASKS.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# StellaOps.Replay.Core — Task Board
|
||||
|
||||
| ID | Status | Description | Dependencies | Exit Criteria |
|
||||
|----|--------|-------------|--------------|---------------|
|
||||
| REPLAY-CORE-185-001 | TODO | Scaffold replay core library (`StellaOps.Replay.Core`) with manifest schema types, canonical JSON utilities, Merkle helpers, DSSE payload builders, and module charter updates referencing `docs/replay/DETERMINISTIC_REPLAY.md`. | Sprint 185 replay planning | Library builds/tests succeed; AGENTS.md updated; integration notes cross-linked. |
|
||||
| REPLAY-CORE-185-002 | TODO | Implement deterministic bundle writer (tar.zst, CAS naming) and hashing abstractions; extend `docs/modules/platform/architecture-overview.md` with “Replay CAS” section. | REPLAY-CORE-185-001 | Bundle writer unit tests pass; documentation merged with examples; CAS layout reproducible. |
|
||||
@@ -0,0 +1,388 @@
|
||||
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;
|
||||
|
||||
namespace StellaOps.Cryptography.Kms.Tests;
|
||||
|
||||
public sealed class CloudKmsClientTests
|
||||
{
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fido2Client_Signs_Verifies_And_Exports()
|
||||
{
|
||||
using var fixture = new EcdsaFixture();
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user