feat(rate-limiting): Implement core rate limiting functionality with configuration, decision-making, metrics, middleware, and service registration
- Add RateLimitConfig for configuration management with YAML binding support. - Introduce RateLimitDecision to encapsulate the result of rate limit checks. - Implement RateLimitMetrics for OpenTelemetry metrics tracking. - Create RateLimitMiddleware for enforcing rate limits on incoming requests. - Develop RateLimitService to orchestrate instance and environment rate limit checks. - Add RateLimitServiceCollectionExtensions for dependency injection registration.
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<KeyManagementDbContext>()
|
||||
.UseInMemoryDatabase(databaseName: $"TemporalTestDb_{Guid.NewGuid()}")
|
||||
.Options;
|
||||
|
||||
_dbContext = new KeyManagementDbContext(options);
|
||||
_timeProvider = new FakeTimeProvider(_currentTime);
|
||||
|
||||
_service = new KeyRotationService(
|
||||
_dbContext,
|
||||
NullLogger<KeyRotationService>.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.Id, "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.Id, "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.Id, "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.Id, "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.Id, "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.Id, "key-2024", duringOverlap);
|
||||
var result2025 = await _service.CheckKeyValidityAsync(anchor.Id, "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.Id, "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.Id, "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.Id, "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.Id, "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.Id, "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<KeyNotFoundException>(
|
||||
() => _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.Id, "key-2024", checkTime);
|
||||
var result2 = await _service.CheckKeyValidityAsync(anchor.Id, "key-2024", checkTime);
|
||||
var result3 = await _service.CheckKeyValidityAsync(anchor.Id, "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.Id, "key-2024", utcTime);
|
||||
var resultPst = await _service.CheckKeyValidityAsync(anchor.Id, "key-2024", pstTime);
|
||||
var resultJst = await _service.CheckKeyValidityAsync(anchor.Id, "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<TrustAnchorEntity> CreateTestAnchorWithTimelineAsync()
|
||||
{
|
||||
var anchor = new TrustAnchorEntity
|
||||
{
|
||||
Id = 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
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TrustAnchorId = anchor.Id,
|
||||
KeyId = "key-2024",
|
||||
Algorithm = "Ed25519",
|
||||
AddedAt = _key2024AddedAt,
|
||||
RevokedAt = _key2024RevokedAt,
|
||||
RevokeReason = "annual-rotation",
|
||||
CreatedBy = "test-user"
|
||||
},
|
||||
new KeyHistoryEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TrustAnchorId = anchor.Id,
|
||||
KeyId = "key-2025",
|
||||
Algorithm = "Ed25519",
|
||||
AddedAt = _key2025AddedAt,
|
||||
RevokedAt = null,
|
||||
RevokeReason = null,
|
||||
CreatedBy = "test-user"
|
||||
}
|
||||
};
|
||||
|
||||
_dbContext.TrustAnchors.Add(anchor);
|
||||
_dbContext.KeyHistories.AddRange(keyHistory);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return anchor;
|
||||
}
|
||||
|
||||
private async Task<TrustAnchorEntity> CreateTestAnchorWithExpiringKeyAsync()
|
||||
{
|
||||
var anchor = new TrustAnchorEntity
|
||||
{
|
||||
Id = 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
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TrustAnchorId = anchor.Id,
|
||||
KeyId = "expiring-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,
|
||||
CreatedBy = "test-user"
|
||||
};
|
||||
|
||||
_dbContext.TrustAnchors.Add(anchor);
|
||||
_dbContext.KeyHistories.Add(keyHistory);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return anchor;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake time provider for testing temporal logic.
|
||||
/// </summary>
|
||||
public class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _currentTime;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset startTime)
|
||||
{
|
||||
_currentTime = startTime;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _currentTime;
|
||||
|
||||
public void SetTime(DateTimeOffset newTime) => _currentTime = newTime;
|
||||
|
||||
public void AdvanceBy(TimeSpan duration) => _currentTime += duration;
|
||||
}
|
||||
Reference in New Issue
Block a user