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.
270 lines
9.6 KiB
C#
270 lines
9.6 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.ObjectModel;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.Cryptography;
|
|
using StellaOps.EvidenceLocker.Core.Builders;
|
|
using StellaOps.EvidenceLocker.Core.Configuration;
|
|
using StellaOps.EvidenceLocker.Core.Domain;
|
|
using StellaOps.EvidenceLocker.Core.Signing;
|
|
using StellaOps.EvidenceLocker.Infrastructure.Signing;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.EvidenceLocker.Tests;
|
|
|
|
public sealed class EvidenceSignatureServiceTests
|
|
{
|
|
private static readonly SigningKeyMaterialOptions TestKeyMaterial = CreateKeyMaterial();
|
|
|
|
[Fact]
|
|
public async Task SignManifestAsync_SignsManifestWithoutTimestamp_WhenTimestampingDisabled()
|
|
{
|
|
var timestampClient = new FakeTimestampAuthorityClient();
|
|
var timeProvider = new TestTimeProvider(new DateTimeOffset(2025, 11, 3, 10, 0, 0, TimeSpan.Zero));
|
|
var service = CreateService(timestampClient, timeProvider);
|
|
|
|
var manifest = CreateManifest();
|
|
var signature = await service.SignManifestAsync(
|
|
manifest.BundleId,
|
|
manifest.TenantId,
|
|
manifest,
|
|
CancellationToken.None);
|
|
|
|
Assert.NotNull(signature);
|
|
Assert.Equal("application/vnd.stella.test+json", signature.PayloadType);
|
|
Assert.NotNull(signature.Payload);
|
|
Assert.NotEmpty(signature.Signature);
|
|
Assert.Null(signature.TimestampedAt);
|
|
Assert.Null(signature.TimestampAuthority);
|
|
Assert.Null(signature.TimestampToken);
|
|
Assert.Equal(0, timestampClient.CallCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SignManifestAsync_AttachesTimestamp_WhenAuthorityClientSucceeds()
|
|
{
|
|
var timestampClient = new FakeTimestampAuthorityClient
|
|
{
|
|
Result = new TimestampResult(
|
|
new DateTimeOffset(2025, 11, 3, 10, 0, 5, TimeSpan.Zero),
|
|
"CN=Test TSA",
|
|
new byte[] { 1, 2, 3 })
|
|
};
|
|
|
|
var timeProvider = new TestTimeProvider(new DateTimeOffset(2025, 11, 3, 10, 0, 0, TimeSpan.Zero));
|
|
var signingOptions = CreateSigningOptions(timestamping: new TimestampingOptions
|
|
{
|
|
Enabled = true,
|
|
Endpoint = "https://tsa.example",
|
|
HashAlgorithm = "SHA256"
|
|
});
|
|
|
|
var service = CreateService(timestampClient, timeProvider, signingOptions);
|
|
var manifest = CreateManifest();
|
|
|
|
var signature = await service.SignManifestAsync(
|
|
manifest.BundleId,
|
|
manifest.TenantId,
|
|
manifest,
|
|
CancellationToken.None);
|
|
|
|
Assert.NotNull(signature);
|
|
Assert.Equal(timestampClient.Result!.Timestamp, signature.TimestampedAt);
|
|
Assert.Equal(timestampClient.Result.Authority, signature.TimestampAuthority);
|
|
Assert.Equal(timestampClient.Result.Token, signature.TimestampToken);
|
|
Assert.Equal(1, timestampClient.CallCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SignManifestAsync_Throws_WhenTimestampRequiredAndClientFails()
|
|
{
|
|
var timestampClient = new FakeTimestampAuthorityClient
|
|
{
|
|
Exception = new InvalidOperationException("TSA offline")
|
|
};
|
|
|
|
var signingOptions = CreateSigningOptions(timestamping: new TimestampingOptions
|
|
{
|
|
Enabled = true,
|
|
Endpoint = "https://tsa.example",
|
|
HashAlgorithm = "SHA256",
|
|
RequireTimestamp = true
|
|
});
|
|
|
|
var service = CreateService(timestampClient, new TestTimeProvider(DateTimeOffset.UtcNow), signingOptions);
|
|
var manifest = CreateManifest();
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => service.SignManifestAsync(
|
|
manifest.BundleId,
|
|
manifest.TenantId,
|
|
manifest,
|
|
CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SignManifestAsync_ProducesDeterministicPayload()
|
|
{
|
|
var timestampClient = new FakeTimestampAuthorityClient();
|
|
var service = CreateService(timestampClient, new TestTimeProvider(DateTimeOffset.UtcNow));
|
|
|
|
var sharedBundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
|
|
var sharedTenantId = TenantId.FromGuid(Guid.NewGuid());
|
|
|
|
var manifestA = CreateManifest(
|
|
metadataOrder: new[] { ("zeta", "1"), ("alpha", "2") },
|
|
bundleId: sharedBundleId,
|
|
tenantId: sharedTenantId);
|
|
var manifestB = CreateManifest(
|
|
metadataOrder: new[] { ("alpha", "2"), ("zeta", "1") },
|
|
bundleId: sharedBundleId,
|
|
tenantId: sharedTenantId);
|
|
|
|
var signatureA = await service.SignManifestAsync(
|
|
manifestA.BundleId,
|
|
manifestA.TenantId,
|
|
manifestA,
|
|
CancellationToken.None);
|
|
|
|
var signatureB = await service.SignManifestAsync(
|
|
manifestB.BundleId,
|
|
manifestB.TenantId,
|
|
manifestB,
|
|
CancellationToken.None);
|
|
|
|
Assert.NotNull(signatureA);
|
|
Assert.NotNull(signatureB);
|
|
|
|
var payloadA = Encoding.UTF8.GetString(Convert.FromBase64String(signatureA!.Payload));
|
|
var payloadB = Encoding.UTF8.GetString(Convert.FromBase64String(signatureB!.Payload));
|
|
Assert.Equal(payloadA, payloadB);
|
|
|
|
using var document = JsonDocument.Parse(payloadA);
|
|
var metadataElement = document.RootElement.GetProperty("metadata");
|
|
using var enumerator = metadataElement.EnumerateObject();
|
|
Assert.True(enumerator.MoveNext());
|
|
Assert.Equal("alpha", enumerator.Current.Name);
|
|
Assert.True(enumerator.MoveNext());
|
|
Assert.Equal("zeta", enumerator.Current.Name);
|
|
}
|
|
|
|
private static EvidenceSignatureService CreateService(
|
|
ITimestampAuthorityClient timestampAuthorityClient,
|
|
TimeProvider timeProvider,
|
|
SigningOptions? signingOptions = null)
|
|
{
|
|
var registry = new CryptoProviderRegistry(new ICryptoProvider[] { new DefaultCryptoProvider() });
|
|
|
|
var options = Options.Create(new EvidenceLockerOptions
|
|
{
|
|
Database = new DatabaseOptions { ConnectionString = "Host=localhost" },
|
|
ObjectStore = new ObjectStoreOptions
|
|
{
|
|
Kind = ObjectStoreKind.FileSystem,
|
|
FileSystem = new FileSystemStoreOptions { RootPath = "." }
|
|
},
|
|
Quotas = new QuotaOptions(),
|
|
Signing = signingOptions ?? CreateSigningOptions()
|
|
});
|
|
|
|
return new EvidenceSignatureService(
|
|
registry,
|
|
timestampAuthorityClient,
|
|
options,
|
|
timeProvider,
|
|
NullLogger<EvidenceSignatureService>.Instance);
|
|
}
|
|
|
|
private static SigningOptions CreateSigningOptions(TimestampingOptions? timestamping = null)
|
|
=> new()
|
|
{
|
|
Enabled = true,
|
|
Algorithm = SignatureAlgorithms.Es256,
|
|
KeyId = "test-key",
|
|
PayloadType = "application/vnd.stella.test+json",
|
|
KeyMaterial = TestKeyMaterial,
|
|
Timestamping = timestamping
|
|
};
|
|
|
|
private static SigningKeyMaterialOptions CreateKeyMaterial()
|
|
{
|
|
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
|
var privatePem = ecdsa.ExportECPrivateKeyPem();
|
|
var publicPem = ecdsa.ExportSubjectPublicKeyInfoPem();
|
|
return new SigningKeyMaterialOptions
|
|
{
|
|
EcPrivateKeyPem = privatePem,
|
|
EcPublicKeyPem = publicPem
|
|
};
|
|
}
|
|
|
|
private static EvidenceBundleManifest CreateManifest(
|
|
(string key, string value)[]? metadataOrder = null,
|
|
EvidenceBundleId? bundleId = null,
|
|
TenantId? tenantId = null)
|
|
{
|
|
metadataOrder ??= new[] { ("alpha", "1"), ("beta", "2") };
|
|
var metadataDictionary = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
foreach (var (key, value) in metadataOrder)
|
|
{
|
|
metadataDictionary[key] = value;
|
|
}
|
|
|
|
var metadata = new ReadOnlyDictionary<string, string>(metadataDictionary);
|
|
|
|
var attributesDictionary = new Dictionary<string, string>(StringComparer.Ordinal)
|
|
{
|
|
["scope"] = "inputs",
|
|
["priority"] = "high"
|
|
};
|
|
var attributes = new ReadOnlyDictionary<string, string>(attributesDictionary);
|
|
|
|
var manifestEntry = new EvidenceManifestEntry(
|
|
"inputs",
|
|
"inputs/config.json",
|
|
new string('a', 64),
|
|
128,
|
|
"application/json",
|
|
attributes);
|
|
|
|
return new EvidenceBundleManifest(
|
|
bundleId ?? EvidenceBundleId.FromGuid(Guid.NewGuid()),
|
|
tenantId ?? TenantId.FromGuid(Guid.NewGuid()),
|
|
EvidenceBundleKind.Evaluation,
|
|
new DateTimeOffset(2025, 11, 3, 9, 30, 0, TimeSpan.Zero),
|
|
metadata,
|
|
new List<EvidenceManifestEntry> { manifestEntry });
|
|
}
|
|
|
|
private sealed class FakeTimestampAuthorityClient : ITimestampAuthorityClient
|
|
{
|
|
public TimestampResult? Result { get; set; }
|
|
public Exception? Exception { get; set; }
|
|
public int CallCount { get; private set; }
|
|
|
|
public Task<TimestampResult?> RequestTimestampAsync(
|
|
ReadOnlyMemory<byte> signature,
|
|
string hashAlgorithm,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
CallCount++;
|
|
if (Exception is not null)
|
|
{
|
|
throw Exception;
|
|
}
|
|
|
|
return Task.FromResult(Result);
|
|
}
|
|
}
|
|
|
|
private sealed class TestTimeProvider(DateTimeOffset fixedUtcNow) : TimeProvider
|
|
{
|
|
public override DateTimeOffset GetUtcNow() => fixedUtcNow;
|
|
}
|
|
}
|