// ----------------------------------------------------------------------------- // 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; /// /// 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) /// [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 { ["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(); } [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; } }