500 lines
17 KiB
C#
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))));
|
|
}
|
|
}
|