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.
186 lines
6.1 KiB
C#
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}'."),
|
|
};
|
|
}
|
|
}
|