Files
git.stella-ops.org/src/BinaryIndex/__Tests/StellaOps.BinaryIndex.DeltaSig.Tests/Integration/DeltaSigEndToEndTests.cs
2026-01-16 18:44:34 +02:00

500 lines
17 KiB
C#

// -----------------------------------------------------------------------------
// DeltaSigEndToEndTests.cs
// Sprint: SPRINT_20260117_003_BINDEX_delta_sig_predicate
// Task: DSP-009 - Integration tests for delta-sig predicate E2E flow
// Description: End-to-end tests for delta-sig generation, signing, submission, and verification
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.BinaryIndex.DeltaSig.Tests.Integration;
[Trait("Category", TestCategories.Integration)]
public sealed class DeltaSigEndToEndTests
{
private static readonly DateTimeOffset FixedTimestamp = new(2026, 1, 16, 12, 0, 0, TimeSpan.Zero);
private readonly FakeTimeProvider _timeProvider;
private readonly MockRekorClient _rekorClient;
private readonly MockSigningService _signingService;
public DeltaSigEndToEndTests()
{
_timeProvider = new FakeTimeProvider(FixedTimestamp);
_rekorClient = new MockRekorClient();
_signingService = new MockSigningService();
}
[Fact]
public async Task FullFlow_GenerateSignSubmitVerify_Succeeds()
{
// Arrange
var service = CreateService();
var beforeBinary = CreateTestBinary("libtest-1.0.so", 10);
var afterBinary = CreateTestBinary("libtest-1.1.so", 12); // 2 new functions
// Act - Step 1: Generate delta-sig predicate
var predicate = await service.GenerateAsync(beforeBinary, afterBinary, CancellationToken.None);
// Assert - predicate created correctly
predicate.Should().NotBeNull();
predicate.PredicateType.Should().Contain("delta-sig");
predicate.Summary.FunctionsAdded.Should().Be(2);
predicate.Summary.FunctionsModified.Should().Be(0);
// Act - Step 2: Sign the predicate
var envelope = await service.SignAsync(predicate, CancellationToken.None);
// Assert - envelope created
envelope.Should().NotBeNull();
envelope.PayloadType.Should().Be("application/vnd.in-toto+json");
envelope.Signatures.Should().NotBeEmpty();
// Act - Step 3: Submit to Rekor
var submission = await service.SubmitToRekorAsync(envelope, CancellationToken.None);
// Assert - submission successful
submission.Success.Should().BeTrue();
submission.EntryId.Should().NotBeNullOrEmpty();
submission.LogIndex.Should().BeGreaterThan(0);
// Act - Step 4: Verify from Rekor
var verification = await service.VerifyFromRekorAsync(submission.EntryId!, CancellationToken.None);
// Assert - verification successful
verification.IsValid.Should().BeTrue();
verification.PredicateType.Should().Contain("delta-sig");
}
[Fact]
public async Task Generate_IdenticalBinaries_ReturnsEmptyDiff()
{
// Arrange
var service = CreateService();
var binary = CreateTestBinary("libtest.so", 5);
// Act
var predicate = await service.GenerateAsync(binary, binary, CancellationToken.None);
// Assert
predicate.Summary.FunctionsAdded.Should().Be(0);
predicate.Summary.FunctionsModified.Should().Be(0);
predicate.Summary.FunctionsRemoved.Should().Be(0);
predicate.Diff.Should().BeEmpty();
}
[Fact]
public async Task Generate_RemovedFunctions_TracksRemovals()
{
// Arrange
var service = CreateService();
var beforeBinary = CreateTestBinary("libtest-1.0.so", 10);
var afterBinary = CreateTestBinary("libtest-1.1.so", 7); // 3 removed
// Act
var predicate = await service.GenerateAsync(beforeBinary, afterBinary, CancellationToken.None);
// Assert
predicate.Summary.FunctionsRemoved.Should().Be(3);
}
[Fact]
public async Task Generate_ModifiedFunctions_TracksModifications()
{
// Arrange
var service = CreateService();
var beforeBinary = CreateTestBinaryWithModifications("libtest-1.0.so", 5, modifyIndices: new[] { 1, 3 });
var afterBinary = CreateTestBinaryWithModifications("libtest-1.1.so", 5, modifyIndices: new[] { 1, 3 }, modified: true);
// Act
var predicate = await service.GenerateAsync(beforeBinary, afterBinary, CancellationToken.None);
// Assert
predicate.Summary.FunctionsModified.Should().Be(2);
}
[Fact]
public async Task Verify_TamperedPredicate_FailsVerification()
{
// Arrange
var service = CreateService();
var beforeBinary = CreateTestBinary("libtest-1.0.so", 5);
var afterBinary = CreateTestBinary("libtest-1.1.so", 6);
var predicate = await service.GenerateAsync(beforeBinary, afterBinary, CancellationToken.None);
var envelope = await service.SignAsync(predicate, CancellationToken.None);
// Tamper with the envelope
var tamperedEnvelope = envelope with
{
Payload = Convert.ToBase64String(Encoding.UTF8.GetBytes("tampered content"))
};
// Act
var verification = await service.VerifyEnvelopeAsync(tamperedEnvelope, CancellationToken.None);
// Assert
verification.IsValid.Should().BeFalse();
verification.FailureReason.Should().Contain("signature");
}
[Fact]
public async Task PolicyGate_WithinLimits_Passes()
{
// Arrange
var service = CreateService();
var beforeBinary = CreateTestBinary("libtest-1.0.so", 10);
var afterBinary = CreateTestBinary("libtest-1.1.so", 12); // 2 added
var predicate = await service.GenerateAsync(beforeBinary, afterBinary, CancellationToken.None);
var policyOptions = new DeltaScopePolicyOptions
{
MaxAddedFunctions = 5,
MaxRemovedFunctions = 5,
MaxModifiedFunctions = 10,
MaxBytesChanged = 10000
};
// Act
var gateResult = await service.EvaluatePolicyAsync(predicate, policyOptions, CancellationToken.None);
// Assert
gateResult.Passed.Should().BeTrue();
gateResult.Violations.Should().BeEmpty();
}
[Fact]
public async Task PolicyGate_ExceedsLimits_FailsWithViolations()
{
// Arrange
var service = CreateService();
var beforeBinary = CreateTestBinary("libtest-1.0.so", 10);
var afterBinary = CreateTestBinary("libtest-1.1.so", 20); // 10 added
var predicate = await service.GenerateAsync(beforeBinary, afterBinary, CancellationToken.None);
var policyOptions = new DeltaScopePolicyOptions
{
MaxAddedFunctions = 5, // Exceeded
MaxRemovedFunctions = 5,
MaxModifiedFunctions = 10,
MaxBytesChanged = 10000
};
// Act
var gateResult = await service.EvaluatePolicyAsync(predicate, policyOptions, CancellationToken.None);
// Assert
gateResult.Passed.Should().BeFalse();
gateResult.Violations.Should().ContainSingle();
gateResult.Violations.First().Should().Contain("added");
}
[Fact]
public async Task SerializeDeserialize_RoundTrip_PreservesData()
{
// Arrange
var service = CreateService();
var beforeBinary = CreateTestBinary("libtest-1.0.so", 5);
var afterBinary = CreateTestBinary("libtest-1.1.so", 7);
var originalPredicate = await service.GenerateAsync(beforeBinary, afterBinary, CancellationToken.None);
// Act
var json = service.SerializePredicate(originalPredicate);
var deserialized = service.DeserializePredicate(json);
// Assert
deserialized.PredicateType.Should().Be(originalPredicate.PredicateType);
deserialized.Summary.FunctionsAdded.Should().Be(originalPredicate.Summary.FunctionsAdded);
deserialized.Subject.Should().HaveCount(originalPredicate.Subject.Count);
}
[Fact]
public async Task Generate_WithSemanticSimilarity_IncludesSimilarityScores()
{
// Arrange
var options = CreateOptions();
options.Value.IncludeSemanticSimilarity = true;
var service = CreateService(options);
var beforeBinary = CreateTestBinaryWithModifications("libtest-1.0.so", 5, modifyIndices: new[] { 2 });
var afterBinary = CreateTestBinaryWithModifications("libtest-1.1.so", 5, modifyIndices: new[] { 2 }, modified: true);
// Act
var predicate = await service.GenerateAsync(beforeBinary, afterBinary, CancellationToken.None);
// Assert
var modifiedFunc = predicate.Diff.FirstOrDefault(d => d.ChangeType == "modified");
modifiedFunc.Should().NotBeNull();
modifiedFunc!.SemanticSimilarity.Should().BeGreaterThan(0);
}
[Fact]
public async Task SubmitToRekor_Offline_ReturnsError()
{
// Arrange
_rekorClient.SetOffline(true);
var service = CreateService();
var predicate = CreateMinimalPredicate();
var envelope = await service.SignAsync(predicate, CancellationToken.None);
// Act
var submission = await service.SubmitToRekorAsync(envelope, CancellationToken.None);
// Assert
submission.Success.Should().BeFalse();
submission.Error.Should().Contain("offline");
}
[Fact]
public async Task Verify_StoredOfflineProof_SucceedsWithoutNetwork()
{
// Arrange
var service = CreateService();
var predicate = CreateMinimalPredicate();
var envelope = await service.SignAsync(predicate, CancellationToken.None);
// Submit and get proof
var submission = await service.SubmitToRekorAsync(envelope, CancellationToken.None);
var proof = await service.GetInclusionProofAsync(submission.EntryId!, CancellationToken.None);
// Go offline
_rekorClient.SetOffline(true);
// Act - verify using stored proof
var verification = await service.VerifyWithStoredProofAsync(envelope, proof, CancellationToken.None);
// Assert
verification.IsValid.Should().BeTrue();
verification.VerificationMode.Should().Be("offline");
}
// Helper methods
private IDeltaSigService CreateService(IOptions<DeltaSigServiceOptions>? options = null)
{
return new DeltaSigService(
options ?? CreateOptions(),
_rekorClient,
_signingService,
_timeProvider,
NullLogger<DeltaSigService>.Instance);
}
private static IOptions<DeltaSigServiceOptions> CreateOptions()
{
return Options.Create(new DeltaSigServiceOptions
{
PredicateType = "https://stellaops.io/delta-sig/v1",
IncludeSemanticSimilarity = false,
RekorUrl = "https://rekor.sigstore.dev"
});
}
private static TestBinaryData CreateTestBinary(string name, int functionCount)
{
var functions = Enumerable.Range(0, functionCount)
.Select(i => new TestFunction(
Name: $"func_{i:D3}",
Hash: ComputeHash($"{name}-func-{i}"),
Size: 100 + i * 10))
.ToImmutableArray();
return new TestBinaryData(
Name: name,
Digest: $"sha256:{ComputeHash(name)}",
Functions: functions);
}
private static TestBinaryData CreateTestBinaryWithModifications(
string name, int functionCount, int[] modifyIndices, bool modified = false)
{
var functions = Enumerable.Range(0, functionCount)
.Select(i =>
{
var suffix = modified && modifyIndices.Contains(i) ? "-modified" : "";
return new TestFunction(
Name: $"func_{i:D3}",
Hash: ComputeHash($"{name}-func-{i}{suffix}"),
Size: 100 + i * 10);
})
.ToImmutableArray();
return new TestBinaryData(
Name: name,
Digest: $"sha256:{ComputeHash(name)}",
Functions: functions);
}
private DeltaSigPredicate CreateMinimalPredicate()
{
return new DeltaSigPredicate(
PredicateType: "https://stellaops.io/delta-sig/v1",
Subject: ImmutableArray.Create(new InTotoSubject(
Name: "test.so",
Digest: ImmutableDictionary<string, string>.Empty.Add("sha256", "abc123"))),
Diff: ImmutableArray<DeltaSigDiffEntry>.Empty,
Summary: new DeltaSigSummary(0, 0, 0, 0),
Timestamp: FixedTimestamp,
BeforeDigest: "sha256:before",
AfterDigest: "sha256:after");
}
private static string ComputeHash(string input)
{
var bytes = Encoding.UTF8.GetBytes(input);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
// Supporting types for tests
public record TestBinaryData(
string Name,
string Digest,
ImmutableArray<TestFunction> Functions);
public record TestFunction(
string Name,
string Hash,
int Size);
public record DeltaSigPredicate(
string PredicateType,
ImmutableArray<InTotoSubject> Subject,
ImmutableArray<DeltaSigDiffEntry> Diff,
DeltaSigSummary Summary,
DateTimeOffset Timestamp,
string BeforeDigest,
string AfterDigest);
public record InTotoSubject(
string Name,
ImmutableDictionary<string, string> Digest);
public record DeltaSigDiffEntry(
string FunctionName,
string ChangeType,
string? BeforeHash,
string? AfterHash,
int BytesDelta,
double? SemanticSimilarity);
public record DeltaSigSummary(
int FunctionsAdded,
int FunctionsRemoved,
int FunctionsModified,
int TotalBytesChanged);
public record DsseEnvelope(
string PayloadType,
string Payload,
ImmutableArray<DsseSignature> Signatures);
public record DsseSignature(
string KeyId,
string Sig);
public record RekorSubmissionResult(
bool Success,
string? EntryId,
long LogIndex,
string? Error);
public record VerificationResult(
bool IsValid,
string? PredicateType,
string? FailureReason,
string? VerificationMode);
public record PolicyGateResult(
bool Passed,
ImmutableArray<string> Violations);
public record InclusionProof(
long TreeSize,
string RootHash,
ImmutableArray<string> Hashes);
public record DeltaScopePolicyOptions
{
public int MaxAddedFunctions { get; init; }
public int MaxRemovedFunctions { get; init; }
public int MaxModifiedFunctions { get; init; }
public int MaxBytesChanged { get; init; }
}
public record DeltaSigServiceOptions
{
public string PredicateType { get; init; } = "https://stellaops.io/delta-sig/v1";
public bool IncludeSemanticSimilarity { get; init; }
public string RekorUrl { get; init; } = "https://rekor.sigstore.dev";
}
public interface IDeltaSigService
{
Task<DeltaSigPredicate> GenerateAsync(TestBinaryData before, TestBinaryData after, CancellationToken ct);
Task<DsseEnvelope> SignAsync(DeltaSigPredicate predicate, CancellationToken ct);
Task<RekorSubmissionResult> SubmitToRekorAsync(DsseEnvelope envelope, CancellationToken ct);
Task<VerificationResult> VerifyFromRekorAsync(string entryId, CancellationToken ct);
Task<VerificationResult> VerifyEnvelopeAsync(DsseEnvelope envelope, CancellationToken ct);
Task<PolicyGateResult> EvaluatePolicyAsync(DeltaSigPredicate predicate, DeltaScopePolicyOptions options, CancellationToken ct);
string SerializePredicate(DeltaSigPredicate predicate);
DeltaSigPredicate DeserializePredicate(string json);
Task<InclusionProof> GetInclusionProofAsync(string entryId, CancellationToken ct);
Task<VerificationResult> VerifyWithStoredProofAsync(DsseEnvelope envelope, InclusionProof proof, CancellationToken ct);
}
public sealed class MockRekorClient
{
private bool _offline;
private long _nextLogIndex = 10000;
private readonly Dictionary<string, InclusionProof> _proofs = new();
public void SetOffline(bool offline) => _offline = offline;
public Task<RekorSubmissionResult> SubmitAsync(byte[] payload, CancellationToken ct)
{
if (_offline)
return Task.FromResult(new RekorSubmissionResult(false, null, 0, "offline"));
var entryId = Guid.NewGuid().ToString("N");
var logIndex = _nextLogIndex++;
_proofs[entryId] = new InclusionProof(logIndex, "root-hash", ImmutableArray.Create("h1", "h2"));
return Task.FromResult(new RekorSubmissionResult(true, entryId, logIndex, null));
}
public Task<InclusionProof?> GetProofAsync(string entryId, CancellationToken ct)
{
if (_offline) return Task.FromResult<InclusionProof?>(null);
_proofs.TryGetValue(entryId, out var proof);
return Task.FromResult(proof);
}
}
public sealed class MockSigningService
{
public Task<DsseEnvelope> SignAsync(string payload, CancellationToken ct)
{
var signature = Convert.ToBase64String(
SHA256.HashData(Encoding.UTF8.GetBytes(payload)));
return Task.FromResult(new DsseEnvelope(
PayloadType: "application/vnd.in-toto+json",
Payload: Convert.ToBase64String(Encoding.UTF8.GetBytes(payload)),
Signatures: ImmutableArray.Create(new DsseSignature("key-1", signature))));
}
}