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(() => 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.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(StringComparer.Ordinal); foreach (var (key, value) in metadataOrder) { metadataDictionary[key] = value; } var metadata = new ReadOnlyDictionary(metadataDictionary); var attributesDictionary = new Dictionary(StringComparer.Ordinal) { ["scope"] = "inputs", ["priority"] = "high" }; var attributes = new ReadOnlyDictionary(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 { manifestEntry }); } private sealed class FakeTimestampAuthorityClient : ITimestampAuthorityClient { public TimestampResult? Result { get; set; } public Exception? Exception { get; set; } public int CallCount { get; private set; } public Task RequestTimestampAsync( ReadOnlyMemory 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; } }