// ----------------------------------------------------------------------------- // TemporalKeyVerificationTests.cs // Sprint: SPRINT_0501_0008_0001_proof_chain_key_rotation // Task: PROOF-KEY-0014 - Temporal verification tests (key valid at time T) // Description: Tests verifying key validity at specific points in time // ----------------------------------------------------------------------------- using System; using System.Threading.Tasks; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using StellaOps.Signer.KeyManagement; using StellaOps.Signer.KeyManagement.Entities; using Xunit; namespace StellaOps.Signer.Tests.KeyManagement; /// /// Temporal key verification tests. /// Validates that keys are correctly checked for validity at specific points in time. /// This is critical for verifying historical proofs that were signed before key rotation. /// public class TemporalKeyVerificationTests : IDisposable { private readonly KeyManagementDbContext _dbContext; private readonly KeyRotationService _service; private readonly FakeTimeProvider _timeProvider; // Timeline: // 2024-01-15: key-2024 added // 2024-06-15: key-2025 added (overlap period begins) // 2025-01-15: key-2024 revoked (overlap period ends) // 2025-06-15: current time private readonly DateTimeOffset _key2024AddedAt = new(2024, 1, 15, 0, 0, 0, TimeSpan.Zero); private readonly DateTimeOffset _key2025AddedAt = new(2024, 6, 15, 0, 0, 0, TimeSpan.Zero); private readonly DateTimeOffset _key2024RevokedAt = new(2025, 1, 15, 0, 0, 0, TimeSpan.Zero); private readonly DateTimeOffset _currentTime = new(2025, 6, 15, 12, 0, 0, TimeSpan.Zero); public TemporalKeyVerificationTests() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: $"TemporalTestDb_{Guid.NewGuid()}") .Options; _dbContext = new KeyManagementDbContext(options); _timeProvider = new FakeTimeProvider(_currentTime); _service = new KeyRotationService( _dbContext, NullLogger.Instance, Options.Create(new KeyRotationOptions { DefaultActor = "test-user", ExpiryWarningDays = 60, MaxKeyAgeDays = 365, DeprecatedAlgorithms = ["RSA-2048", "SHA1-RSA"] }), _timeProvider); } public void Dispose() { _dbContext.Dispose(); GC.SuppressFinalize(this); } #region Key Lifecycle Timeline Tests [Fact] public async Task CheckKeyValidity_KeyNotYetAdded_ReturnsNotYetValid() { // Arrange var anchor = await CreateTestAnchorWithTimelineAsync(); var beforeKeyAdded = _key2024AddedAt.AddDays(-30); // Dec 2023 // Act var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", beforeKeyAdded); // Assert result.IsValid.Should().BeFalse(); result.Status.Should().Be(KeyStatus.NotYetValid); result.InvalidReason.Should().Contain("not yet added"); } [Fact] public async Task CheckKeyValidity_KeyActiveNoRevocation_ReturnsValid() { // Arrange var anchor = await CreateTestAnchorWithTimelineAsync(); var duringActiveWindow = _key2024AddedAt.AddMonths(3); // April 2024 // Act var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", duringActiveWindow); // Assert result.IsValid.Should().BeTrue(); result.Status.Should().Be(KeyStatus.Active); result.AddedAt.Should().Be(_key2024AddedAt); } [Fact] public async Task CheckKeyValidity_KeyRevokedButSignedBefore_ReturnsValid() { // Arrange - proof was signed during overlap period before key-2024 was revoked var anchor = await CreateTestAnchorWithTimelineAsync(); var signedDuringOverlap = _key2024RevokedAt.AddDays(-30); // Dec 2024 // Act var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", signedDuringOverlap); // Assert - key-2024 should be valid because signature was made before revocation result.IsValid.Should().BeTrue(); result.Status.Should().Be(KeyStatus.Active); } [Fact] public async Task CheckKeyValidity_KeyRevokedAndSignedAfter_ReturnsRevoked() { // Arrange - proof was signed after key-2024 was revoked var anchor = await CreateTestAnchorWithTimelineAsync(); var signedAfterRevocation = _key2024RevokedAt.AddDays(30); // Feb 2025 // Act var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", signedAfterRevocation); // Assert - key-2024 should be invalid because signature was made after revocation result.IsValid.Should().BeFalse(); result.Status.Should().Be(KeyStatus.Revoked); result.RevokedAt.Should().Be(_key2024RevokedAt); } [Fact] public async Task CheckKeyValidity_NewKeyAfterOldRevoked_ReturnsValid() { // Arrange - proof was signed with key-2025 after key-2024 was revoked var anchor = await CreateTestAnchorWithTimelineAsync(); var signedWithNewKey = _key2024RevokedAt.AddDays(30); // Feb 2025 // Act var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2025", signedWithNewKey); // Assert - key-2025 should be valid result.IsValid.Should().BeTrue(); result.Status.Should().Be(KeyStatus.Active); result.AddedAt.Should().Be(_key2025AddedAt); } #endregion #region Overlap Period Tests [Fact] public async Task CheckKeyValidity_BothKeysValidDuringOverlap_BothReturnValid() { // Arrange - during overlap period (Jun 2024 - Jan 2025), both keys should be valid var anchor = await CreateTestAnchorWithTimelineAsync(); var duringOverlap = new DateTimeOffset(2024, 9, 15, 0, 0, 0, TimeSpan.Zero); // Sep 2024 // Act var result2024 = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", duringOverlap); var result2025 = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2025", duringOverlap); // Assert - both keys should be valid during overlap result2024.IsValid.Should().BeTrue(); result2024.Status.Should().Be(KeyStatus.Active); result2025.IsValid.Should().BeTrue(); result2025.Status.Should().Be(KeyStatus.Active); } [Fact] public async Task CheckKeyValidity_ExactlyAtRevocationTime_ReturnsRevoked() { // Arrange - checking exactly at the moment of revocation var anchor = await CreateTestAnchorWithTimelineAsync(); // Act - at exact revocation time, key is already revoked var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", _key2024RevokedAt); // Assert - at revocation time, key should be considered revoked result.IsValid.Should().BeFalse(); result.Status.Should().Be(KeyStatus.Revoked); } [Fact] public async Task CheckKeyValidity_OneMillisecondBeforeRevocation_ReturnsValid() { // Arrange - one millisecond before revocation var anchor = await CreateTestAnchorWithTimelineAsync(); var justBeforeRevocation = _key2024RevokedAt.AddMilliseconds(-1); // Act var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", justBeforeRevocation); // Assert - key should still be valid result.IsValid.Should().BeTrue(); result.Status.Should().Be(KeyStatus.Active); } #endregion #region Key Expiry Tests [Fact] public async Task CheckKeyValidity_KeyExpiredButSignedBefore_ReturnsValid() { // Arrange - key with expiry date var anchor = await CreateTestAnchorWithExpiringKeyAsync(); var expiryDate = new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero); var signedBeforeExpiry = expiryDate.AddDays(-30); // Feb 2025 // Act var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "expiring-key", signedBeforeExpiry); // Assert - should be valid because signed before expiry result.IsValid.Should().BeTrue(); result.Status.Should().Be(KeyStatus.Active); } [Fact] public async Task CheckKeyValidity_KeyExpiredAndSignedAfter_ReturnsExpired() { // Arrange - key with expiry date var anchor = await CreateTestAnchorWithExpiringKeyAsync(); var expiryDate = new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero); var signedAfterExpiry = expiryDate.AddDays(30); // April 2025 // Act var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "expiring-key", signedAfterExpiry); // Assert - should be invalid because signed after expiry result.IsValid.Should().BeFalse(); result.Status.Should().Be(KeyStatus.Expired); } #endregion #region Unknown Key Tests [Fact] public async Task CheckKeyValidity_UnknownKey_ReturnsUnknown() { // Arrange var anchor = await CreateTestAnchorWithTimelineAsync(); // Act var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "nonexistent-key", _currentTime); // Assert result.IsValid.Should().BeFalse(); result.Status.Should().Be(KeyStatus.Unknown); result.InvalidReason.Should().Contain("not found"); } [Fact] public async Task CheckKeyValidity_UnknownAnchor_ThrowsKeyNotFoundException() { // Arrange var unknownAnchorId = Guid.NewGuid(); // Act & Assert await Assert.ThrowsAsync( () => _service.CheckKeyValidityAsync(unknownAnchorId, "any-key", _currentTime)); } #endregion #region Determinism Tests [Fact] public async Task CheckKeyValidity_SameInputs_ReturnsSameResult() { // Arrange - determinism is critical for audit verification var anchor = await CreateTestAnchorWithTimelineAsync(); var checkTime = new DateTimeOffset(2024, 9, 15, 10, 30, 45, TimeSpan.Zero); // Act - call multiple times var result1 = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", checkTime); var result2 = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", checkTime); var result3 = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", checkTime); // Assert - all results should be identical result1.Should().BeEquivalentTo(result2); result2.Should().BeEquivalentTo(result3); } [Fact] public async Task CheckKeyValidity_DifferentTimezones_SameUtcTime_ReturnsSameResult() { // Arrange - different timezone representations of same moment var anchor = await CreateTestAnchorWithTimelineAsync(); var utcTime = new DateTimeOffset(2024, 9, 15, 12, 0, 0, TimeSpan.Zero); var pstTime = new DateTimeOffset(2024, 9, 15, 4, 0, 0, TimeSpan.FromHours(-8)); var jstTime = new DateTimeOffset(2024, 9, 15, 21, 0, 0, TimeSpan.FromHours(9)); // Act var resultUtc = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", utcTime); var resultPst = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", pstTime); var resultJst = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-2024", jstTime); // Assert - all should return same result (same UTC instant) resultUtc.IsValid.Should().Be(resultPst.IsValid); resultPst.IsValid.Should().Be(resultJst.IsValid); resultUtc.Status.Should().Be(resultPst.Status); resultPst.Status.Should().Be(resultJst.Status); } #endregion #region Helper Methods private async Task CreateTestAnchorWithTimelineAsync() { var anchor = new TrustAnchorEntity { AnchorId = Guid.NewGuid(), PurlPattern = "pkg:npm/*", AllowedKeyIds = ["key-2024", "key-2025"], RevokedKeyIds = ["key-2024"], PolicyVersion = "v1.0.0", CreatedAt = _key2024AddedAt, UpdatedAt = _key2024RevokedAt }; var keyHistory = new[] { new KeyHistoryEntity { HistoryId = Guid.NewGuid(), AnchorId = anchor.AnchorId, KeyId = "key-2024", PublicKey = "-----BEGIN PUBLIC KEY-----\ntest-key-2024\n-----END PUBLIC KEY-----", Algorithm = "Ed25519", AddedAt = _key2024AddedAt, RevokedAt = _key2024RevokedAt, RevokeReason = "annual-rotation", CreatedAt = _key2024AddedAt }, new KeyHistoryEntity { HistoryId = Guid.NewGuid(), AnchorId = anchor.AnchorId, KeyId = "key-2025", PublicKey = "-----BEGIN PUBLIC KEY-----\ntest-key-2025\n-----END PUBLIC KEY-----", Algorithm = "Ed25519", AddedAt = _key2025AddedAt, RevokedAt = null, RevokeReason = null, CreatedAt = _key2025AddedAt } }; _dbContext.TrustAnchors.Add(anchor); _dbContext.KeyHistory.AddRange(keyHistory); await _dbContext.SaveChangesAsync(); return anchor; } private async Task CreateTestAnchorWithExpiringKeyAsync() { var anchor = new TrustAnchorEntity { AnchorId = Guid.NewGuid(), PurlPattern = "pkg:pypi/*", AllowedKeyIds = ["expiring-key"], RevokedKeyIds = [], PolicyVersion = "v1.0.0", CreatedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), UpdatedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) }; var keyHistory = new KeyHistoryEntity { HistoryId = Guid.NewGuid(), AnchorId = anchor.AnchorId, KeyId = "expiring-key", PublicKey = "-----BEGIN PUBLIC KEY-----\ntest-expiring-key\n-----END PUBLIC KEY-----", Algorithm = "Ed25519", AddedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), ExpiresAt = new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero), RevokedAt = null, RevokeReason = null, CreatedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) }; _dbContext.TrustAnchors.Add(anchor); _dbContext.KeyHistory.Add(keyHistory); await _dbContext.SaveChangesAsync(); return anchor; } #endregion } // Note: FakeTimeProvider is defined in KeyRotationServiceTests.cs