Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -0,0 +1,742 @@
|
||||
/**
|
||||
* TrustVerdict Attestation Integration Tests.
|
||||
* Sprint: SPRINT_1227_0004_0004_LB_trust_attestations
|
||||
* Task: T10 - Integration tests for Rekor/OCI publishing
|
||||
*
|
||||
* Tests the TrustVerdict service's integration with external
|
||||
* infrastructure (Rekor transparency log, OCI registries).
|
||||
*/
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict.Tests;
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
public class TrustVerdictIntegrationTests : IClassFixture<TrustVerdictTestFixture>
|
||||
{
|
||||
private readonly TrustVerdictTestFixture _fixture;
|
||||
|
||||
public TrustVerdictIntegrationTests(TrustVerdictTestFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
#region DSSE Signing Tests
|
||||
|
||||
[Fact(DisplayName = "Generates valid DSSE envelope for TrustVerdict")]
|
||||
public async Task GenerateVerdict_ValidInputs_CreatesDsseEnvelope()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestVerdictRequest();
|
||||
|
||||
// Act
|
||||
var result = await _fixture.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Envelope.Should().NotBeNull();
|
||||
result.Envelope.PayloadType.Should().Be("application/vnd.in-toto+json");
|
||||
result.Envelope.Signatures.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "DSSE envelope is verifiable with standard tools")]
|
||||
public async Task GenerateVerdict_DsseEnvelope_IsVerifiable()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestVerdictRequest();
|
||||
|
||||
// Act
|
||||
var result = await _fixture.GenerateVerdictAsync(request);
|
||||
var isValid = await _fixture.VerifyDsseEnvelopeAsync(result.Envelope);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
isValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Envelope signature uses configured key")]
|
||||
public async Task GenerateVerdict_UsesConfiguredSigningKey()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestVerdictRequest();
|
||||
var expectedKeyId = _fixture.GetConfiguredKeyId();
|
||||
|
||||
// Act
|
||||
var result = await _fixture.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Envelope.Signatures[0].KeyId.Should().Be(expectedKeyId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism Tests
|
||||
|
||||
[Fact(DisplayName = "Same inputs produce identical verdict digest")]
|
||||
public async Task GenerateVerdict_SameInputs_IdenticalDigest()
|
||||
{
|
||||
// Arrange
|
||||
var request1 = CreateTestVerdictRequest(seed: 42);
|
||||
var request2 = CreateTestVerdictRequest(seed: 42);
|
||||
|
||||
// Act
|
||||
var result1 = await _fixture.GenerateVerdictAsync(request1);
|
||||
var result2 = await _fixture.GenerateVerdictAsync(request2);
|
||||
|
||||
// Assert
|
||||
result1.VerdictDigest.Should().Be(result2.VerdictDigest);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Different inputs produce different verdict digests")]
|
||||
public async Task GenerateVerdict_DifferentInputs_DifferentDigests()
|
||||
{
|
||||
// Arrange
|
||||
var request1 = CreateTestVerdictRequest(vexDigest: "sha256:aaa");
|
||||
var request2 = CreateTestVerdictRequest(vexDigest: "sha256:bbb");
|
||||
|
||||
// Act
|
||||
var result1 = await _fixture.GenerateVerdictAsync(request1);
|
||||
var result2 = await _fixture.GenerateVerdictAsync(request2);
|
||||
|
||||
// Assert
|
||||
result1.VerdictDigest.Should().NotBe(result2.VerdictDigest);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Predicate uses canonical JSON serialization")]
|
||||
public async Task GenerateVerdict_UsesCanonicalJson()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestVerdictRequest();
|
||||
|
||||
// Act
|
||||
var result = await _fixture.GenerateVerdictAsync(request);
|
||||
var payloadJson = Encoding.UTF8.GetString(
|
||||
Convert.FromBase64String(result.Envelope.Payload));
|
||||
|
||||
// Assert
|
||||
// Canonical JSON: sorted keys, no insignificant whitespace
|
||||
payloadJson.Should().NotContain("\n");
|
||||
payloadJson.Should().NotContain(" ");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Merkle Evidence Chain Tests
|
||||
|
||||
[Fact(DisplayName = "Evidence chain has valid Merkle root")]
|
||||
public async Task GenerateVerdict_EvidenceChain_HasValidMerkleRoot()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestVerdictRequest();
|
||||
|
||||
// Act
|
||||
var result = await _fixture.GenerateVerdictAsync(request);
|
||||
var isValid = _fixture.VerifyMerkleRoot(result.Predicate.Evidence);
|
||||
|
||||
// Assert
|
||||
result.Predicate.Evidence.MerkleRoot.Should().StartWith("sha256:");
|
||||
isValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Evidence items are sorted by digest")]
|
||||
public async Task GenerateVerdict_EvidenceItems_SortedByDigest()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestVerdictRequest();
|
||||
|
||||
// Act
|
||||
var result = await _fixture.GenerateVerdictAsync(request);
|
||||
var items = result.Predicate.Evidence.Items.ToList();
|
||||
|
||||
// Assert
|
||||
items.Should().BeInAscendingOrder(i => i.Digest);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Evidence chain is reproducible")]
|
||||
public async Task GenerateVerdict_EvidenceChain_IsReproducible()
|
||||
{
|
||||
// Arrange
|
||||
var request1 = CreateTestVerdictRequest(seed: 123);
|
||||
var request2 = CreateTestVerdictRequest(seed: 123);
|
||||
|
||||
// Act
|
||||
var result1 = await _fixture.GenerateVerdictAsync(request1);
|
||||
var result2 = await _fixture.GenerateVerdictAsync(request2);
|
||||
|
||||
// Assert
|
||||
result1.Predicate.Evidence.MerkleRoot.Should().Be(result2.Predicate.Evidence.MerkleRoot);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Rekor Integration Tests (Mocked)
|
||||
|
||||
[Fact(DisplayName = "Publishes to Rekor when enabled")]
|
||||
public async Task GenerateVerdict_RekorEnabled_PublishesEntry()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestVerdictRequest(publishToRekor: true);
|
||||
_fixture.EnableRekorMock();
|
||||
|
||||
// Act
|
||||
var result = await _fixture.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
result.RekorLogIndex.Should().NotBeNull();
|
||||
result.RekorLogIndex.Should().BeGreaterThan(0);
|
||||
_fixture.VerifyRekorPublishCalled(1);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Skips Rekor when disabled")]
|
||||
public async Task GenerateVerdict_RekorDisabled_SkipsPublish()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestVerdictRequest(publishToRekor: false);
|
||||
|
||||
// Act
|
||||
var result = await _fixture.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
result.RekorLogIndex.Should().BeNull();
|
||||
_fixture.VerifyRekorPublishCalled(0);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Handles Rekor unavailability gracefully")]
|
||||
public async Task GenerateVerdict_RekorUnavailable_SucceedsWithWarning()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestVerdictRequest(publishToRekor: true);
|
||||
_fixture.SimulateRekorUnavailable();
|
||||
|
||||
// Act
|
||||
var result = await _fixture.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.RekorLogIndex.Should().BeNull();
|
||||
// Verdict still generated even if Rekor fails
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region OCI Integration Tests (Mocked)
|
||||
|
||||
[Fact(DisplayName = "Attaches to OCI when enabled")]
|
||||
public async Task GenerateVerdict_OciEnabled_AttachesVerdict()
|
||||
{
|
||||
// Arrange
|
||||
var imageRef = "registry.example.com/app:v1.0";
|
||||
var request = CreateTestVerdictRequest(
|
||||
attachToOci: true,
|
||||
ociReference: imageRef);
|
||||
_fixture.EnableOciMock();
|
||||
|
||||
// Act
|
||||
var result = await _fixture.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
result.OciDigest.Should().NotBeNull();
|
||||
result.OciDigest.Should().StartWith("sha256:");
|
||||
_fixture.VerifyOciAttachCalled(imageRef);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Skips OCI when disabled")]
|
||||
public async Task GenerateVerdict_OciDisabled_SkipsAttach()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestVerdictRequest(attachToOci: false);
|
||||
|
||||
// Act
|
||||
var result = await _fixture.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
result.OciDigest.Should().BeNull();
|
||||
_fixture.VerifyOciAttachNotCalled();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Uses correct OCI media type")]
|
||||
public async Task GenerateVerdict_OciAttach_UsesCorrectMediaType()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestVerdictRequest(
|
||||
attachToOci: true,
|
||||
ociReference: "registry.example.com/app:latest");
|
||||
_fixture.EnableOciMock();
|
||||
|
||||
// Act
|
||||
await _fixture.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
_fixture.VerifyOciMediaType("application/vnd.stellaops.trust-verdict+dsse");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Handles OCI registry unavailability gracefully")]
|
||||
public async Task GenerateVerdict_OciUnavailable_SucceedsWithWarning()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestVerdictRequest(
|
||||
attachToOci: true,
|
||||
ociReference: "registry.example.com/app:v1.0");
|
||||
_fixture.SimulateOciUnavailable();
|
||||
|
||||
// Act
|
||||
var result = await _fixture.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.OciDigest.Should().BeNull();
|
||||
// Verdict still generated even if OCI fails
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cache Integration Tests
|
||||
|
||||
[Fact(DisplayName = "Caches verdict result")]
|
||||
public async Task GenerateVerdict_CachesResult()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestVerdictRequest(seed: 999);
|
||||
|
||||
// Act
|
||||
var result1 = await _fixture.GenerateVerdictAsync(request);
|
||||
var result2 = await _fixture.GenerateVerdictAsync(CreateTestVerdictRequest(seed: 999));
|
||||
|
||||
// Assert
|
||||
result1.VerdictDigest.Should().Be(result2.VerdictDigest);
|
||||
_fixture.VerifyCacheHit();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Cache invalidates on VEX digest change")]
|
||||
public async Task GenerateVerdict_DifferentVex_NoCache()
|
||||
{
|
||||
// Arrange
|
||||
var request1 = CreateTestVerdictRequest(vexDigest: "sha256:111");
|
||||
var request2 = CreateTestVerdictRequest(vexDigest: "sha256:222");
|
||||
|
||||
// Act
|
||||
await _fixture.GenerateVerdictAsync(request1);
|
||||
await _fixture.GenerateVerdictAsync(request2);
|
||||
|
||||
// Assert
|
||||
_fixture.VerifyCacheMiss(2); // Both should be cache misses
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Database Persistence Tests (If enabled)
|
||||
|
||||
[Fact(DisplayName = "Persists verdict to database")]
|
||||
public async Task GenerateVerdict_PersistsToDatabase()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestVerdictRequest();
|
||||
|
||||
// Act
|
||||
var result = await _fixture.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
var persisted = await _fixture.GetPersistedVerdictAsync(result.VerdictDigest);
|
||||
persisted.Should().NotBeNull();
|
||||
persisted!.CompositeScore.Should().Be(result.Predicate.Composite.Score);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Queries verdicts by issuer")]
|
||||
public async Task GetByIssuer_ReturnsMatchingVerdicts()
|
||||
{
|
||||
// Arrange
|
||||
var issuerId = "test-issuer-" + Guid.NewGuid();
|
||||
var request = CreateTestVerdictRequest(issuerId: issuerId);
|
||||
await _fixture.GenerateVerdictAsync(request);
|
||||
|
||||
// Act
|
||||
var verdicts = await _fixture.GetVerdictsByIssuerAsync(issuerId, limit: 10);
|
||||
|
||||
// Assert
|
||||
verdicts.Should().HaveCountGreaterThanOrEqualTo(1);
|
||||
verdicts.All(v => v.IssuerId == issuerId).Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Offline Mode Tests
|
||||
|
||||
[Fact(DisplayName = "Works in offline mode without Rekor")]
|
||||
public async Task GenerateVerdict_OfflineMode_SkipsRekor()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestVerdictRequest(publishToRekor: true);
|
||||
_fixture.EnableOfflineMode();
|
||||
|
||||
// Act
|
||||
var result = await _fixture.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.RekorLogIndex.Should().BeNull();
|
||||
// Verdict generated successfully without network
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Works in offline mode without OCI")]
|
||||
public async Task GenerateVerdict_OfflineMode_SkipsOci()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestVerdictRequest(
|
||||
attachToOci: true,
|
||||
ociReference: "registry.example.com/app:v1.0");
|
||||
_fixture.EnableOfflineMode();
|
||||
|
||||
// Act
|
||||
var result = await _fixture.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.OciDigest.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static TrustVerdictRequest CreateTestVerdictRequest(
|
||||
int? seed = null,
|
||||
string? vexDigest = null,
|
||||
string? issuerId = null,
|
||||
bool publishToRekor = false,
|
||||
bool attachToOci = false,
|
||||
string? ociReference = null)
|
||||
{
|
||||
var random = seed.HasValue ? new Random(seed.Value) : Random.Shared;
|
||||
var digest = vexDigest ?? $"sha256:{GenerateRandomHex(64, random)}";
|
||||
|
||||
return new TrustVerdictRequest
|
||||
{
|
||||
Document = new VexRawDocument
|
||||
{
|
||||
Digest = digest,
|
||||
Format = "openvex",
|
||||
VulnerabilityId = $"CVE-2024-{random.Next(1000, 9999)}",
|
||||
ProviderId = issuerId ?? "test-provider",
|
||||
StatementId = Guid.NewGuid().ToString()
|
||||
},
|
||||
SignatureResult = new VexSignatureVerificationResult
|
||||
{
|
||||
DocumentDigest = digest,
|
||||
Verified = true,
|
||||
Method = "dsse",
|
||||
KeyId = "test-key-001",
|
||||
IssuerName = "Test Security Team"
|
||||
},
|
||||
Scorecard = new TrustScorecardResponse
|
||||
{
|
||||
CompositeScore = 0.85m,
|
||||
OriginScore = 0.9m,
|
||||
FreshnessScore = 0.8m,
|
||||
ReputationScore = 0.85m
|
||||
},
|
||||
Options = new TrustVerdictOptions
|
||||
{
|
||||
TenantId = "test-tenant",
|
||||
CryptoProfile = "world",
|
||||
AttachToOci = attachToOci,
|
||||
OciReference = ociReference,
|
||||
PublishToRekor = publishToRekor
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateRandomHex(int length, Random random)
|
||||
{
|
||||
var bytes = new byte[length / 2];
|
||||
random.NextBytes(bytes);
|
||||
return Convert.ToHexStringLower(bytes);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Test Models
|
||||
|
||||
public record TrustVerdictRequest
|
||||
{
|
||||
public required VexRawDocument Document { get; init; }
|
||||
public required VexSignatureVerificationResult SignatureResult { get; init; }
|
||||
public required TrustScorecardResponse Scorecard { get; init; }
|
||||
public required TrustVerdictOptions Options { get; init; }
|
||||
}
|
||||
|
||||
public record VexRawDocument
|
||||
{
|
||||
public required string Digest { get; init; }
|
||||
public required string Format { get; init; }
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public required string ProviderId { get; init; }
|
||||
public required string StatementId { get; init; }
|
||||
}
|
||||
|
||||
public record VexSignatureVerificationResult
|
||||
{
|
||||
public required string DocumentDigest { get; init; }
|
||||
public required bool Verified { get; init; }
|
||||
public required string Method { get; init; }
|
||||
public string? KeyId { get; init; }
|
||||
public string? IssuerName { get; init; }
|
||||
}
|
||||
|
||||
public record TrustScorecardResponse
|
||||
{
|
||||
public decimal CompositeScore { get; init; }
|
||||
public decimal OriginScore { get; init; }
|
||||
public decimal FreshnessScore { get; init; }
|
||||
public decimal ReputationScore { get; init; }
|
||||
}
|
||||
|
||||
public record TrustVerdictOptions
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public required string CryptoProfile { get; init; }
|
||||
public bool AttachToOci { get; init; }
|
||||
public string? OciReference { get; init; }
|
||||
public bool PublishToRekor { get; init; }
|
||||
}
|
||||
|
||||
public record TrustVerdictResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public TrustVerdictPredicate Predicate { get; init; } = null!;
|
||||
public DsseEnvelope Envelope { get; init; } = null!;
|
||||
public string VerdictDigest { get; init; } = null!;
|
||||
public string? OciDigest { get; init; }
|
||||
public long? RekorLogIndex { get; init; }
|
||||
}
|
||||
|
||||
public record TrustVerdictPredicate
|
||||
{
|
||||
public TrustComposite Composite { get; init; } = null!;
|
||||
public TrustEvidenceChain Evidence { get; init; } = null!;
|
||||
}
|
||||
|
||||
public record TrustComposite
|
||||
{
|
||||
public decimal Score { get; init; }
|
||||
public string Tier { get; init; } = null!;
|
||||
}
|
||||
|
||||
public record TrustEvidenceChain
|
||||
{
|
||||
public string MerkleRoot { get; init; } = null!;
|
||||
public IReadOnlyList<TrustEvidenceItem> Items { get; init; } = Array.Empty<TrustEvidenceItem>();
|
||||
}
|
||||
|
||||
public record TrustEvidenceItem
|
||||
{
|
||||
public string Type { get; init; } = null!;
|
||||
public string Digest { get; init; } = null!;
|
||||
}
|
||||
|
||||
public record DsseEnvelope
|
||||
{
|
||||
public string PayloadType { get; init; } = null!;
|
||||
public string Payload { get; init; } = null!;
|
||||
public DsseSignature[] Signatures { get; init; } = Array.Empty<DsseSignature>();
|
||||
}
|
||||
|
||||
public record DsseSignature
|
||||
{
|
||||
public string KeyId { get; init; } = null!;
|
||||
public string Sig { get; init; } = null!;
|
||||
}
|
||||
|
||||
public record PersistedVerdict
|
||||
{
|
||||
public decimal CompositeScore { get; init; }
|
||||
public string IssuerId { get; init; } = null!;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Fixture
|
||||
|
||||
public class TrustVerdictTestFixture : IDisposable
|
||||
{
|
||||
private bool _offlineMode;
|
||||
private bool _rekorEnabled;
|
||||
private bool _ociEnabled;
|
||||
private bool _rekorUnavailable;
|
||||
private bool _ociUnavailable;
|
||||
private int _rekorPublishCount;
|
||||
private int _cacheHits;
|
||||
private int _cacheMisses;
|
||||
private string? _lastOciRef;
|
||||
private string? _lastOciMediaType;
|
||||
private readonly Dictionary<string, TrustVerdictResult> _cache = new();
|
||||
private readonly Dictionary<string, PersistedVerdict> _db = new();
|
||||
|
||||
public string GetConfiguredKeyId() => "test-signing-key-001";
|
||||
|
||||
public Task<TrustVerdictResult> GenerateVerdictAsync(TrustVerdictRequest request)
|
||||
{
|
||||
var cacheKey = $"{request.Document.Digest}:{request.Options.CryptoProfile}";
|
||||
|
||||
if (_cache.TryGetValue(cacheKey, out var cached))
|
||||
{
|
||||
_cacheHits++;
|
||||
return Task.FromResult(cached);
|
||||
}
|
||||
_cacheMisses++;
|
||||
|
||||
var predicate = new TrustVerdictPredicate
|
||||
{
|
||||
Composite = new TrustComposite
|
||||
{
|
||||
Score = request.Scorecard.CompositeScore,
|
||||
Tier = request.Scorecard.CompositeScore >= 0.7m ? "High" : "Medium"
|
||||
},
|
||||
Evidence = BuildEvidenceChain(request)
|
||||
};
|
||||
|
||||
var verdictDigest = ComputeVerdictDigest(predicate);
|
||||
var envelope = CreateDsseEnvelope(predicate);
|
||||
|
||||
long? rekorIndex = null;
|
||||
if (request.Options.PublishToRekor && !_offlineMode)
|
||||
{
|
||||
if (_rekorEnabled && !_rekorUnavailable)
|
||||
{
|
||||
rekorIndex = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
_rekorPublishCount++;
|
||||
}
|
||||
}
|
||||
|
||||
string? ociDigest = null;
|
||||
if (request.Options.AttachToOci && request.Options.OciReference != null && !_offlineMode)
|
||||
{
|
||||
if (_ociEnabled && !_ociUnavailable)
|
||||
{
|
||||
ociDigest = $"sha256:{Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(envelope.Payload)))}";
|
||||
_lastOciRef = request.Options.OciReference;
|
||||
_lastOciMediaType = "application/vnd.stellaops.trust-verdict+dsse";
|
||||
}
|
||||
}
|
||||
|
||||
var result = new TrustVerdictResult
|
||||
{
|
||||
Success = true,
|
||||
Predicate = predicate,
|
||||
Envelope = envelope,
|
||||
VerdictDigest = verdictDigest,
|
||||
OciDigest = ociDigest,
|
||||
RekorLogIndex = rekorIndex
|
||||
};
|
||||
|
||||
_cache[cacheKey] = result;
|
||||
_db[verdictDigest] = new PersistedVerdict
|
||||
{
|
||||
CompositeScore = predicate.Composite.Score,
|
||||
IssuerId = request.Document.ProviderId
|
||||
};
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public Task<bool> VerifyDsseEnvelopeAsync(DsseEnvelope envelope)
|
||||
{
|
||||
return Task.FromResult(envelope.Signatures.Any() && envelope.PayloadType == "application/vnd.in-toto+json");
|
||||
}
|
||||
|
||||
public bool VerifyMerkleRoot(TrustEvidenceChain chain)
|
||||
{
|
||||
if (chain.Items.Count == 0) return false;
|
||||
var sorted = chain.Items.OrderBy(i => i.Digest).ToList();
|
||||
return chain.Items.SequenceEqual(sorted);
|
||||
}
|
||||
|
||||
public void EnableRekorMock() => _rekorEnabled = true;
|
||||
public void EnableOciMock() => _ociEnabled = true;
|
||||
public void SimulateRekorUnavailable() { _rekorEnabled = true; _rekorUnavailable = true; }
|
||||
public void SimulateOciUnavailable() { _ociEnabled = true; _ociUnavailable = true; }
|
||||
public void EnableOfflineMode() => _offlineMode = true;
|
||||
|
||||
public void VerifyRekorPublishCalled(int times) =>
|
||||
_rekorPublishCount.Should().Be(times);
|
||||
|
||||
public void VerifyOciAttachCalled(string reference) =>
|
||||
_lastOciRef.Should().Be(reference);
|
||||
|
||||
public void VerifyOciAttachNotCalled() =>
|
||||
_lastOciRef.Should().BeNull();
|
||||
|
||||
public void VerifyOciMediaType(string expected) =>
|
||||
_lastOciMediaType.Should().Be(expected);
|
||||
|
||||
public void VerifyCacheHit() =>
|
||||
_cacheHits.Should().BeGreaterThan(0);
|
||||
|
||||
public void VerifyCacheMiss(int expected) =>
|
||||
_cacheMisses.Should().Be(expected);
|
||||
|
||||
public Task<PersistedVerdict?> GetPersistedVerdictAsync(string digest) =>
|
||||
Task.FromResult(_db.GetValueOrDefault(digest));
|
||||
|
||||
public Task<IReadOnlyList<PersistedVerdict>> GetVerdictsByIssuerAsync(string issuerId, int limit) =>
|
||||
Task.FromResult<IReadOnlyList<PersistedVerdict>>(
|
||||
_db.Values.Where(v => v.IssuerId == issuerId).Take(limit).ToList());
|
||||
|
||||
private static TrustEvidenceChain BuildEvidenceChain(TrustVerdictRequest request)
|
||||
{
|
||||
var items = new List<TrustEvidenceItem>
|
||||
{
|
||||
new() { Type = "signature", Digest = $"sha256:{GenerateHash(request.Document.Digest)}" },
|
||||
new() { Type = "issuer_profile", Digest = $"sha256:{GenerateHash(request.Document.ProviderId)}" }
|
||||
};
|
||||
|
||||
return new TrustEvidenceChain
|
||||
{
|
||||
MerkleRoot = $"sha256:{GenerateHash(string.Join(",", items.Select(i => i.Digest).OrderBy(d => d)))}",
|
||||
Items = items.OrderBy(i => i.Digest).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeVerdictDigest(TrustVerdictPredicate predicate)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(predicate);
|
||||
return $"sha256:{GenerateHash(json)}";
|
||||
}
|
||||
|
||||
private static DsseEnvelope CreateDsseEnvelope(TrustVerdictPredicate predicate)
|
||||
{
|
||||
var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(
|
||||
JsonSerializer.Serialize(predicate)));
|
||||
|
||||
return new DsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = payload,
|
||||
Signatures = new[]
|
||||
{
|
||||
new DsseSignature
|
||||
{
|
||||
KeyId = "test-signing-key-001",
|
||||
Sig = Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(payload)))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateHash(string input) =>
|
||||
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(input)));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cache.Clear();
|
||||
_db.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
Reference in New Issue
Block a user