Files
git.stella-ops.org/src/__Libraries/StellaOps.Cryptography.Kms/Fido2KmsClient.cs
master 2eb6852d34
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Add unit tests for SBOM ingestion and transformation
- 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.
2025-11-04 07:49:39 +02:00

186 lines
6.1 KiB
C#

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