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:
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}'."),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user