// -----------------------------------------------------------------------------
// 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