Add determinism tests for verdict artifact generation and update SHA256 sums script
- Implemented comprehensive tests for verdict artifact generation to ensure deterministic outputs across various scenarios, including identical inputs, parallel execution, and change ordering. - Created helper methods for generating sample verdict inputs and computing canonical hashes. - Added tests to validate the stability of canonical hashes, proof spine ordering, and summary statistics. - Introduced a new PowerShell script to update SHA256 sums for files, ensuring accurate hash generation and file integrity checks.
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ReplayTokenSecurityTests.cs
|
||||
// Sprint: SPRINT_5100_0010_0001_evidencelocker_tests
|
||||
// Tasks: REPLAY-5100-001, REPLAY-5100-002, REPLAY-5100-003
|
||||
// Description: Model L0 security tests for Replay Token
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Cryptography;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Audit.ReplayToken.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Security tests for Replay Token generation and verification.
|
||||
/// Implements Model L0 test requirements:
|
||||
/// - Tamper detection test: modified replay token → rejected (REPLAY-5100-002)
|
||||
/// - Token issuance test: valid request → token generated with correct claims (REPLAY-5100-003)
|
||||
/// Note: REPLAY-5100-001 (token expiration) is BLOCKED - ReplayToken does not currently support expiration.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Category", "ReplayTokenSecurity")]
|
||||
public sealed class ReplayTokenSecurityTests
|
||||
{
|
||||
// REPLAY-5100-002: Tamper detection tests
|
||||
|
||||
[Fact]
|
||||
public void TamperedToken_ModifiedValue_VerificationFails()
|
||||
{
|
||||
// Arrange
|
||||
var generator = CreateGenerator();
|
||||
var request = CreateRequest();
|
||||
var token = generator.Generate(request);
|
||||
|
||||
// Tamper with the token value
|
||||
var tamperedValue = TamperHash(token.Value);
|
||||
var tamperedToken = new ReplayToken(tamperedValue, token.GeneratedAt, token.Algorithm, token.Version);
|
||||
|
||||
// Act
|
||||
var isValid = generator.Verify(tamperedToken, request);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse("tampered token value should fail verification");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TamperedToken_SingleBitFlip_VerificationFails()
|
||||
{
|
||||
// Arrange
|
||||
var generator = CreateGenerator();
|
||||
var request = CreateRequest();
|
||||
var token = generator.Generate(request);
|
||||
|
||||
// Flip a single bit in the token value
|
||||
var chars = token.Value.ToCharArray();
|
||||
chars[0] = chars[0] == 'a' ? 'b' : 'a';
|
||||
var tamperedValue = new string(chars);
|
||||
var tamperedToken = new ReplayToken(tamperedValue, token.GeneratedAt, token.Algorithm, token.Version);
|
||||
|
||||
// Act
|
||||
var isValid = generator.Verify(tamperedToken, request);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse("single-bit tampered token should fail verification");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TamperedToken_ModifiedAlgorithm_ParsedCorrectlyButVerificationFails()
|
||||
{
|
||||
// Arrange
|
||||
var generator = CreateGenerator();
|
||||
var request = CreateRequest();
|
||||
var token = generator.Generate(request);
|
||||
|
||||
// Create token with different algorithm claim (but same value)
|
||||
var tamperedToken = new ReplayToken(token.Value, token.GeneratedAt, "SHA-512", token.Version);
|
||||
|
||||
// Act - Verification should still work based on computed hash
|
||||
var isValid = generator.Verify(tamperedToken, request);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeTrue("algorithm claim doesn't affect verification (value-based)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TamperedRequest_AddedField_VerificationFails()
|
||||
{
|
||||
// Arrange
|
||||
var generator = CreateGenerator();
|
||||
var originalRequest = CreateRequest();
|
||||
var token = generator.Generate(originalRequest);
|
||||
|
||||
var tamperedRequest = new ReplayTokenRequest
|
||||
{
|
||||
FeedManifests = originalRequest.FeedManifests,
|
||||
RulesVersion = originalRequest.RulesVersion,
|
||||
RulesHash = originalRequest.RulesHash,
|
||||
InputHashes = originalRequest.InputHashes,
|
||||
// Add extra field
|
||||
AdditionalContext = new Dictionary<string, string> { ["malicious"] = "data" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var isValid = generator.Verify(token, tamperedRequest);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse("request with added field should fail verification");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TamperedRequest_RemovedField_VerificationFails()
|
||||
{
|
||||
// Arrange
|
||||
var generator = CreateGenerator();
|
||||
var originalRequest = new ReplayTokenRequest
|
||||
{
|
||||
FeedManifests = ["sha256:feed1", "sha256:feed2"],
|
||||
RulesVersion = "v1",
|
||||
RulesHash = "sha256:rules",
|
||||
InputHashes = ["sha256:input1"]
|
||||
};
|
||||
var token = generator.Generate(originalRequest);
|
||||
|
||||
var tamperedRequest = new ReplayTokenRequest
|
||||
{
|
||||
FeedManifests = ["sha256:feed1"], // Removed one
|
||||
RulesVersion = "v1",
|
||||
RulesHash = "sha256:rules",
|
||||
InputHashes = ["sha256:input1"]
|
||||
};
|
||||
|
||||
// Act
|
||||
var isValid = generator.Verify(token, tamperedRequest);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse("request with removed field should fail verification");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TamperedRequest_ModifiedValue_VerificationFails()
|
||||
{
|
||||
// Arrange
|
||||
var generator = CreateGenerator();
|
||||
var originalRequest = CreateRequest();
|
||||
var token = generator.Generate(originalRequest);
|
||||
|
||||
var tamperedRequest = new ReplayTokenRequest
|
||||
{
|
||||
FeedManifests = originalRequest.FeedManifests,
|
||||
RulesVersion = "v2", // Changed version
|
||||
RulesHash = originalRequest.RulesHash,
|
||||
InputHashes = originalRequest.InputHashes
|
||||
};
|
||||
|
||||
// Act
|
||||
var isValid = generator.Verify(token, tamperedRequest);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse("request with modified value should fail verification");
|
||||
}
|
||||
|
||||
// REPLAY-5100-003: Token issuance tests
|
||||
|
||||
[Fact]
|
||||
public void GenerateToken_ValidRequest_HasCorrectAlgorithm()
|
||||
{
|
||||
// Arrange
|
||||
var generator = CreateGenerator();
|
||||
var request = CreateRequest();
|
||||
|
||||
// Act
|
||||
var token = generator.Generate(request);
|
||||
|
||||
// Assert
|
||||
token.Algorithm.Should().Be(ReplayToken.DefaultAlgorithm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateToken_ValidRequest_HasCorrectVersion()
|
||||
{
|
||||
// Arrange
|
||||
var generator = CreateGenerator();
|
||||
var request = CreateRequest();
|
||||
|
||||
// Act
|
||||
var token = generator.Generate(request);
|
||||
|
||||
// Assert
|
||||
token.Version.Should().Be(ReplayToken.DefaultVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateToken_ValidRequest_HasValidSha256Format()
|
||||
{
|
||||
// Arrange
|
||||
var generator = CreateGenerator();
|
||||
var request = CreateRequest();
|
||||
|
||||
// Act
|
||||
var token = generator.Generate(request);
|
||||
|
||||
// Assert
|
||||
token.Value.Should().NotBeNullOrEmpty();
|
||||
token.Value.Length.Should().Be(64, "SHA-256 hex should be 64 characters");
|
||||
token.Value.Should().MatchRegex("^[0-9a-f]{64}$", "should be lowercase hex");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateToken_ValidRequest_HasCorrectTimestamp()
|
||||
{
|
||||
// Arrange
|
||||
var fixedTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
var generator = CreateGenerator(fixedTime);
|
||||
var request = CreateRequest();
|
||||
|
||||
// Act
|
||||
var token = generator.Generate(request);
|
||||
|
||||
// Assert
|
||||
token.GeneratedAt.Should().Be(fixedTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateToken_ValidRequest_CanonicalFormatIsCorrect()
|
||||
{
|
||||
// Arrange
|
||||
var generator = CreateGenerator();
|
||||
var request = CreateRequest();
|
||||
|
||||
// Act
|
||||
var token = generator.Generate(request);
|
||||
|
||||
// Assert
|
||||
token.Canonical.Should().StartWith("replay:v");
|
||||
token.Canonical.Should().Contain(":SHA-256:");
|
||||
token.Canonical.Split(':').Should().HaveCount(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateToken_EmptyRequest_StillGeneratesValidToken()
|
||||
{
|
||||
// Arrange
|
||||
var generator = CreateGenerator();
|
||||
var emptyRequest = new ReplayTokenRequest();
|
||||
|
||||
// Act
|
||||
var token = generator.Generate(emptyRequest);
|
||||
|
||||
// Assert
|
||||
token.Should().NotBeNull();
|
||||
token.Value.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateToken_NullInputs_HandledGracefully()
|
||||
{
|
||||
// Arrange
|
||||
var generator = CreateGenerator();
|
||||
var request = new ReplayTokenRequest
|
||||
{
|
||||
FeedManifests = null,
|
||||
RulesVersion = null,
|
||||
InputHashes = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var token = generator.Generate(request);
|
||||
|
||||
// Assert
|
||||
token.Should().NotBeNull();
|
||||
token.Value.Length.Should().Be(64);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateToken_DeterministicAcrossMultipleCalls()
|
||||
{
|
||||
// Arrange
|
||||
var fixedTime = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var generator = CreateGenerator(fixedTime);
|
||||
var request = CreateRequest();
|
||||
|
||||
// Act
|
||||
var tokens = Enumerable.Range(0, 10)
|
||||
.Select(_ => generator.Generate(request))
|
||||
.ToList();
|
||||
|
||||
// Assert
|
||||
tokens.Select(t => t.Value).Distinct().Should().HaveCount(1,
|
||||
"same request should produce same token value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateToken_DifferentRequests_ProduceDifferentTokens()
|
||||
{
|
||||
// Arrange
|
||||
var generator = CreateGenerator();
|
||||
var request1 = new ReplayTokenRequest { InputHashes = ["sha256:input1"] };
|
||||
var request2 = new ReplayTokenRequest { InputHashes = ["sha256:input2"] };
|
||||
|
||||
// Act
|
||||
var token1 = generator.Generate(request1);
|
||||
var token2 = generator.Generate(request2);
|
||||
|
||||
// Assert
|
||||
token1.Value.Should().NotBe(token2.Value,
|
||||
"different requests should produce different tokens");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseToken_RoundTrip_PreservesValues()
|
||||
{
|
||||
// Arrange
|
||||
var generator = CreateGenerator();
|
||||
var request = CreateRequest();
|
||||
var originalToken = generator.Generate(request);
|
||||
|
||||
// Act
|
||||
var parsed = ReplayToken.Parse(originalToken.Canonical);
|
||||
|
||||
// Assert
|
||||
parsed.Value.Should().Be(originalToken.Value);
|
||||
parsed.Algorithm.Should().Be(originalToken.Algorithm);
|
||||
parsed.Version.Should().Be(originalToken.Version);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("invalid")]
|
||||
[InlineData("replay:")]
|
||||
[InlineData("replay:v1.0")]
|
||||
[InlineData("replay:v1.0:SHA-256")]
|
||||
[InlineData("notreplay:v1.0:SHA-256:abc")]
|
||||
public void ParseToken_InvalidFormat_ThrowsException(string invalidCanonical)
|
||||
{
|
||||
// Act
|
||||
var act = () => ReplayToken.Parse(invalidCanonical);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<Exception>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Token_Equality_BasedOnValue()
|
||||
{
|
||||
// Arrange
|
||||
var value = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
var token1 = new ReplayToken(value, DateTimeOffset.UtcNow);
|
||||
var token2 = new ReplayToken(value, DateTimeOffset.UtcNow.AddHours(1)); // Different time
|
||||
|
||||
// Act & Assert
|
||||
token1.Equals(token2).Should().BeTrue("equality should be based on value only");
|
||||
token1.GetHashCode().Should().Be(token2.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Token_Equality_CaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var value1 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
var value2 = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
|
||||
var token1 = new ReplayToken(value1, DateTimeOffset.UtcNow);
|
||||
var token2 = new ReplayToken(value2, DateTimeOffset.UtcNow);
|
||||
|
||||
// Act & Assert
|
||||
token1.Equals(token2).Should().BeTrue("value comparison should be case-insensitive");
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private static Sha256ReplayTokenGenerator CreateGenerator(DateTimeOffset? fixedTime = null)
|
||||
{
|
||||
var cryptoHash = DefaultCryptoHash.CreateForTests();
|
||||
var timeProvider = new FixedTimeProvider(fixedTime ?? DateTimeOffset.UtcNow);
|
||||
return new Sha256ReplayTokenGenerator(cryptoHash, timeProvider);
|
||||
}
|
||||
|
||||
private static ReplayTokenRequest CreateRequest()
|
||||
{
|
||||
return new ReplayTokenRequest
|
||||
{
|
||||
FeedManifests = ["sha256:feed1", "sha256:feed2"],
|
||||
RulesVersion = "v1",
|
||||
RulesHash = "sha256:rules",
|
||||
LatticePolicyVersion = "lattice-v1",
|
||||
LatticePolicyHash = "sha256:lattice",
|
||||
InputHashes = ["sha256:input1", "sha256:input2"],
|
||||
ScoringConfigVersion = "score-v1",
|
||||
EvidenceHashes = ["sha256:evidence1"]
|
||||
};
|
||||
}
|
||||
|
||||
private static string TamperHash(string hash)
|
||||
{
|
||||
// Create a completely different hash value
|
||||
var chars = hash.ToCharArray();
|
||||
for (int i = 0; i < chars.Length; i++)
|
||||
{
|
||||
chars[i] = chars[i] == 'f' ? '0' : (char)(chars[i] + 1);
|
||||
}
|
||||
return new string(chars);
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => now;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,15 @@
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.5.4" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.4" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\..\\__Libraries\\StellaOps.Audit.ReplayToken\\StellaOps.Audit.ReplayToken.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user