Add unit tests for SBOM ingestion and transformation
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:
master
2025-11-04 07:49:39 +02:00
parent f72c5c513a
commit 2eb6852d34
491 changed files with 39445 additions and 3917 deletions

View 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);
}

View 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();
}
}
}

View 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;
}

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

View 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; }
}

View 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);
}

View 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();
}
}

View 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;
}

View File

@@ -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);
}

View File

@@ -0,0 +1,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Cryptography.Kms.Tests")]

View File

@@ -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);

View 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;
}
}
}

View 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);
}

View 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;
}

View File

@@ -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;
}
}

View File

@@ -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" />

View File

@@ -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.

View File

@@ -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);
}

View File

@@ -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));
}
}

View File

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

View File

@@ -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);

View 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)

View 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. |

View File

@@ -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();
}
}
}