Files
git.stella-ops.org/src/__Tests/StellaOps.Audit.ReplayToken.Tests/ReplayTokenSecurityTests.cs

652 lines
21 KiB
C#

// -----------------------------------------------------------------------------
// 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:
/// - Token expiration test: expired replay token → rejected (REPLAY-5100-001)
/// - Tamper detection test: modified replay token → rejected (REPLAY-5100-002)
/// - Token issuance test: valid request → token generated with correct claims (REPLAY-5100-003)
/// </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");
}
// REPLAY-5100-001: Token expiration tests
[Fact]
public void ExpiredToken_VerifyWithExpiration_ReturnsExpired()
{
// Arrange
var fixedTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
var generator = CreateGenerator(fixedTime);
var request = CreateRequest();
// Generate token with 1 hour expiration
var token = generator.GenerateWithExpiration(request, TimeSpan.FromHours(1));
// Advance time past expiration
var futureGenerator = CreateGenerator(fixedTime.AddHours(2));
// Act
var result = futureGenerator.VerifyWithExpiration(token, request);
// Assert
result.Should().Be(ReplayTokenVerificationResult.Expired, "expired token should be rejected");
}
[Fact]
public void NotYetExpiredToken_VerifyWithExpiration_ReturnsValid()
{
// Arrange
var fixedTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
var generator = CreateGenerator(fixedTime);
var request = CreateRequest();
// Generate token with 1 hour expiration
var token = generator.GenerateWithExpiration(request, TimeSpan.FromHours(1));
// Use same time (not expired)
var result = generator.VerifyWithExpiration(token, request);
// Assert
result.Should().Be(ReplayTokenVerificationResult.Valid, "valid, not-yet-expired token should be accepted");
}
[Fact]
public void ExpiredToken_IsExpired_ReturnsTrue()
{
// Arrange
var now = DateTimeOffset.UtcNow;
var expiresAt = now.AddHours(-1); // Already expired
var token = new ReplayToken("abc123", now.AddHours(-2), expiresAt, ReplayToken.DefaultAlgorithm, ReplayToken.VersionWithExpiration);
// Act
var isExpired = token.IsExpired(now);
// Assert
isExpired.Should().BeTrue("token with past expiry should be expired");
}
[Fact]
public void NotYetExpiredToken_IsExpired_ReturnsFalse()
{
// Arrange
var now = DateTimeOffset.UtcNow;
var expiresAt = now.AddHours(1); // Expires in 1 hour
var token = new ReplayToken("abc123", now, expiresAt, ReplayToken.DefaultAlgorithm, ReplayToken.VersionWithExpiration);
// Act
var isExpired = token.IsExpired(now);
// Assert
isExpired.Should().BeFalse("token with future expiry should not be expired");
}
[Fact]
public void TokenWithoutExpiration_IsExpired_ReturnsFalse()
{
// Arrange - v1.0 token without expiration
var token = new ReplayToken("abc123", DateTimeOffset.UtcNow);
// Act
var isExpired = token.IsExpired();
// Assert
isExpired.Should().BeFalse("v1.0 token without expiration should never be expired");
}
[Fact]
public void GenerateWithExpiration_UsesDefaultExpiration_WhenNotSpecified()
{
// Arrange
var fixedTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
var generator = CreateGenerator(fixedTime);
var request = CreateRequest();
// Act
var token = generator.GenerateWithExpiration(request);
// Assert
token.ExpiresAt.Should().Be(fixedTime + ReplayToken.DefaultExpiration);
token.Version.Should().Be(ReplayToken.VersionWithExpiration);
}
[Fact]
public void GenerateWithExpiration_UsesCustomExpiration()
{
// Arrange
var fixedTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
var generator = CreateGenerator(fixedTime);
var request = CreateRequest();
var customExpiration = TimeSpan.FromMinutes(30);
// Act
var token = generator.GenerateWithExpiration(request, customExpiration);
// Assert
token.ExpiresAt.Should().Be(fixedTime + customExpiration);
}
[Fact]
public void TokenWithExpiration_CanonicalFormat_IncludesExpiryTimestamp()
{
// Arrange
var fixedTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
var expiresAt = fixedTime.AddHours(1);
var token = new ReplayToken("abc123", fixedTime, expiresAt, ReplayToken.DefaultAlgorithm, ReplayToken.VersionWithExpiration);
// Act
var canonical = token.Canonical;
// Assert
canonical.Should().Contain(expiresAt.ToUnixTimeSeconds().ToString());
canonical.Split(':').Should().HaveCount(5, "v2.0 format should have 5 parts including expiry");
}
[Fact]
public void ParseToken_WithExpiration_RoundTrip_PreservesExpiration()
{
// Arrange
var fixedTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
var generator = CreateGenerator(fixedTime);
var request = CreateRequest();
var originalToken = generator.GenerateWithExpiration(request, TimeSpan.FromHours(2));
// Act
var parsed = ReplayToken.Parse(originalToken.Canonical);
// Assert
parsed.Value.Should().Be(originalToken.Value);
parsed.Version.Should().Be(ReplayToken.VersionWithExpiration);
parsed.ExpiresAt.Should().Be(originalToken.ExpiresAt);
}
[Fact]
public void TamperedToken_WithExpiration_VerifyWithExpiration_ReturnsInvalid()
{
// Arrange
var fixedTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
var generator = CreateGenerator(fixedTime);
var request = CreateRequest();
var token = generator.GenerateWithExpiration(request, TimeSpan.FromHours(1));
// Tamper with the token value
var tamperedValue = TamperHash(token.Value);
var tamperedToken = new ReplayToken(tamperedValue, token.GeneratedAt, token.ExpiresAt, token.Algorithm, token.Version);
// Act
var result = generator.VerifyWithExpiration(tamperedToken, request);
// Assert
result.Should().Be(ReplayTokenVerificationResult.Invalid, "tampered token should be invalid");
}
[Fact]
public void GetTimeToExpiration_ReturnsRemainingTime()
{
// Arrange
var now = DateTimeOffset.UtcNow;
var expiresAt = now.AddHours(2);
var token = new ReplayToken("abc123", now, expiresAt, ReplayToken.DefaultAlgorithm, ReplayToken.VersionWithExpiration);
// Act
var remaining = token.GetTimeToExpiration(now);
// Assert
remaining.Should().NotBeNull();
remaining.Value.Should().BeCloseTo(TimeSpan.FromHours(2), TimeSpan.FromSeconds(1));
}
[Fact]
public void GetTimeToExpiration_ExpiredToken_ReturnsNull()
{
// Arrange
var now = DateTimeOffset.UtcNow;
var expiresAt = now.AddHours(-1); // Already expired
var token = new ReplayToken("abc123", now.AddHours(-2), expiresAt, ReplayToken.DefaultAlgorithm, ReplayToken.VersionWithExpiration);
// Act
var remaining = token.GetTimeToExpiration(now);
// Assert
remaining.Should().BeNull("expired token should have no remaining time");
}
[Fact]
public void GetTimeToExpiration_NoExpiration_ReturnsNull()
{
// Arrange - v1.0 token without expiration
var token = new ReplayToken("abc123", DateTimeOffset.UtcNow);
// Act
var remaining = token.GetTimeToExpiration();
// Assert
remaining.Should().BeNull("v1.0 token without expiration has no remaining time concept");
}
[Fact]
public void TryParse_ValidToken_ReturnsTrue()
{
// Arrange
var canonical = "replay:v1.0:SHA-256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1";
// Act
var success = ReplayToken.TryParse(canonical, out var token);
// Assert
success.Should().BeTrue();
token.Should().NotBeNull();
token!.Value.Should().Be("abc123def456abc123def456abc123def456abc123def456abc123def456abc1");
}
[Fact]
public void TryParse_InvalidToken_ReturnsFalse()
{
// Arrange
var invalid = "not-a-valid-token";
// Act
var success = ReplayToken.TryParse(invalid, out var token);
// Assert
success.Should().BeFalse();
token.Should().BeNull();
}
// 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;
}
}