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.
292 lines
11 KiB
C#
292 lines
11 KiB
C#
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);
|
|
}
|