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:
master
2025-12-17 18:02:37 +02:00
parent 394b57f6bf
commit 8bbfe4d2d2
211 changed files with 47179 additions and 1590 deletions

View File

@@ -0,0 +1,352 @@
// -----------------------------------------------------------------------------
// KeyRotationWorkflowIntegrationTests.cs
// Sprint: SPRINT_0501_0008_0001_proof_chain_key_rotation
// Task: PROOF-KEY-0013 - Integration tests for rotation workflow
// Description: End-to-end integration tests for the full key rotation workflow
// -----------------------------------------------------------------------------
using System;
using System.Net;
using System.Net.Http.Json;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Signer.KeyManagement;
using StellaOps.Signer.KeyManagement.Entities;
using StellaOps.Signer.WebService.Endpoints;
using Xunit;
namespace StellaOps.Signer.Tests.Integration;
/// <summary>
/// Integration tests for the complete key rotation workflow.
/// Tests the full lifecycle: add key → transition period → revoke old key.
/// </summary>
public class KeyRotationWorkflowIntegrationTests : IClassFixture<WebApplicationFactory<Program>>, IAsyncLifetime
{
private readonly WebApplicationFactory<Program> _factory;
private readonly HttpClient _client;
private Guid _testAnchorId;
public KeyRotationWorkflowIntegrationTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Use in-memory database for tests
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<KeyManagementDbContext>));
if (descriptor != null)
{
services.Remove(descriptor);
}
services.AddDbContext<KeyManagementDbContext>(options =>
{
options.UseInMemoryDatabase($"IntegrationTestDb_{Guid.NewGuid()}");
});
});
});
_client = _factory.CreateClient();
}
public async Task InitializeAsync()
{
// Create a test trust anchor
using var scope = _factory.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<KeyManagementDbContext>();
_testAnchorId = Guid.NewGuid();
var anchor = new TrustAnchorEntity
{
Id = _testAnchorId,
PurlPattern = "pkg:npm/*",
AllowedKeyIds = ["initial-key"],
RevokedKeyIds = [],
PolicyVersion = "v1.0.0",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
dbContext.TrustAnchors.Add(anchor);
dbContext.KeyHistories.Add(new KeyHistoryEntity
{
Id = Guid.NewGuid(),
TrustAnchorId = _testAnchorId,
KeyId = "initial-key",
Algorithm = "Ed25519",
AddedAt = DateTimeOffset.UtcNow.AddMonths(-6),
CreatedBy = "system"
});
await dbContext.SaveChangesAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
#region Full Rotation Workflow Tests
[Fact]
public async Task FullRotationWorkflow_AddNewKey_TransitionPeriod_RevokeOldKey()
{
// Step 1: Add new key (begin transition period)
var addKeyRequest = new AddKeyRequestDto
{
KeyId = "new-key-2025",
PublicKey = TestKeys.Ed25519PublicKeyPem,
Algorithm = "Ed25519"
};
var addResponse = await _client.PostAsJsonAsync(
$"/api/v1/anchors/{_testAnchorId}/keys",
addKeyRequest);
addResponse.StatusCode.Should().Be(HttpStatusCode.Created);
var addResult = await addResponse.Content.ReadFromJsonAsync<AddKeyResponseDto>();
addResult!.AllowedKeyIds.Should().Contain("initial-key");
addResult.AllowedKeyIds.Should().Contain("new-key-2025");
// Step 2: Verify both keys are valid during transition period
var validity1 = await _client.GetFromJsonAsync<KeyValidityResponseDto>(
$"/api/v1/anchors/{_testAnchorId}/keys/initial-key/validity?signedAt={DateTimeOffset.UtcNow:O}");
var validity2 = await _client.GetFromJsonAsync<KeyValidityResponseDto>(
$"/api/v1/anchors/{_testAnchorId}/keys/new-key-2025/validity?signedAt={DateTimeOffset.UtcNow:O}");
validity1!.IsValid.Should().BeTrue();
validity2!.IsValid.Should().BeTrue();
// Step 3: Revoke old key
var revokeRequest = new RevokeKeyRequestDto
{
Reason = "rotation-complete"
};
var revokeResponse = await _client.PostAsJsonAsync(
$"/api/v1/anchors/{_testAnchorId}/keys/initial-key/revoke",
revokeRequest);
revokeResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var revokeResult = await revokeResponse.Content.ReadFromJsonAsync<RevokeKeyResponseDto>();
revokeResult!.AllowedKeyIds.Should().NotContain("initial-key");
revokeResult.AllowedKeyIds.Should().Contain("new-key-2025");
revokeResult.RevokedKeyIds.Should().Contain("initial-key");
// Step 4: Verify key history is complete
var history = await _client.GetFromJsonAsync<KeyHistoryResponseDto>(
$"/api/v1/anchors/{_testAnchorId}/keys/history");
history!.Entries.Should().HaveCount(2);
var oldKeyEntry = history.Entries.First(e => e.KeyId == "initial-key");
oldKeyEntry.RevokedAt.Should().NotBeNull();
oldKeyEntry.RevokeReason.Should().Be("rotation-complete");
var newKeyEntry = history.Entries.First(e => e.KeyId == "new-key-2025");
newKeyEntry.RevokedAt.Should().BeNull();
}
[Fact]
public async Task HistoricalProofVerification_SignedBeforeRevocation_RemainsValid()
{
// Arrange: add and revoke a key
var addRequest = new AddKeyRequestDto
{
KeyId = "old-key",
PublicKey = TestKeys.Ed25519PublicKeyPem,
Algorithm = "Ed25519"
};
await _client.PostAsJsonAsync($"/api/v1/anchors/{_testAnchorId}/keys", addRequest);
// Record time before revocation
var signedBeforeRevocation = DateTimeOffset.UtcNow;
// Revoke the key
var revokeRequest = new RevokeKeyRequestDto { Reason = "test-revocation" };
await _client.PostAsJsonAsync(
$"/api/v1/anchors/{_testAnchorId}/keys/old-key/revoke",
revokeRequest);
// Act: check validity at time before revocation
var validity = await _client.GetFromJsonAsync<KeyValidityResponseDto>(
$"/api/v1/anchors/{_testAnchorId}/keys/old-key/validity?signedAt={signedBeforeRevocation:O}");
// Assert: key should be valid for proofs signed before revocation
validity!.IsValid.Should().BeTrue("proofs signed before revocation should remain valid");
}
[Fact]
public async Task HistoricalProofVerification_SignedAfterRevocation_IsInvalid()
{
// Arrange: add a key, then revoke it
var addRequest = new AddKeyRequestDto
{
KeyId = "revoked-key",
PublicKey = TestKeys.Ed25519PublicKeyPem,
Algorithm = "Ed25519"
};
await _client.PostAsJsonAsync($"/api/v1/anchors/{_testAnchorId}/keys", addRequest);
var revokeRequest = new RevokeKeyRequestDto { Reason = "test-revocation" };
await _client.PostAsJsonAsync(
$"/api/v1/anchors/{_testAnchorId}/keys/revoked-key/revoke",
revokeRequest);
// Act: check validity at time after revocation
var signedAfterRevocation = DateTimeOffset.UtcNow.AddMinutes(5);
var validity = await _client.GetFromJsonAsync<KeyValidityResponseDto>(
$"/api/v1/anchors/{_testAnchorId}/keys/revoked-key/validity?signedAt={signedAfterRevocation:O}");
// Assert: key should be invalid for proofs signed after revocation
validity!.IsValid.Should().BeFalse("proofs signed after revocation should be invalid");
validity.Status.Should().Be("Revoked");
}
#endregion
#region Audit Trail Tests
[Fact]
public async Task AddKey_CreatesAuditLogEntry()
{
// Arrange
var request = new AddKeyRequestDto
{
KeyId = "audited-key",
PublicKey = TestKeys.Ed25519PublicKeyPem,
Algorithm = "Ed25519"
};
// Act
var response = await _client.PostAsJsonAsync(
$"/api/v1/anchors/{_testAnchorId}/keys",
request);
// Assert
var result = await response.Content.ReadFromJsonAsync<AddKeyResponseDto>();
result!.AuditLogId.Should().NotBeNull("all key operations should create audit log entries");
}
[Fact]
public async Task RevokeKey_CreatesAuditLogEntry()
{
// Arrange: first add a key
var addRequest = new AddKeyRequestDto
{
KeyId = "key-to-revoke",
PublicKey = TestKeys.Ed25519PublicKeyPem,
Algorithm = "Ed25519"
};
await _client.PostAsJsonAsync($"/api/v1/anchors/{_testAnchorId}/keys", addRequest);
// Act
var revokeRequest = new RevokeKeyRequestDto { Reason = "audit-test" };
var response = await _client.PostAsJsonAsync(
$"/api/v1/anchors/{_testAnchorId}/keys/key-to-revoke/revoke",
revokeRequest);
// Assert
var result = await response.Content.ReadFromJsonAsync<RevokeKeyResponseDto>();
result!.AuditLogId.Should().NotBeNull("all key operations should create audit log entries");
}
#endregion
#region Rotation Warnings Tests
[Fact]
public async Task GetRotationWarnings_ReturnsRelevantWarnings()
{
// Act
var response = await _client.GetAsync(
$"/api/v1/anchors/{_testAnchorId}/keys/warnings");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var warnings = await response.Content.ReadFromJsonAsync<RotationWarningsResponseDto>();
warnings.Should().NotBeNull();
warnings!.AnchorId.Should().Be(_testAnchorId);
}
#endregion
#region Error Handling Tests
[Fact]
public async Task AddKey_DuplicateKeyId_Returns400()
{
// Arrange: add a key
var request = new AddKeyRequestDto
{
KeyId = "duplicate-key",
PublicKey = TestKeys.Ed25519PublicKeyPem,
Algorithm = "Ed25519"
};
await _client.PostAsJsonAsync($"/api/v1/anchors/{_testAnchorId}/keys", request);
// Act: try to add same key again
var response = await _client.PostAsJsonAsync(
$"/api/v1/anchors/{_testAnchorId}/keys",
request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task RevokeKey_NonexistentKey_Returns404()
{
// Arrange
var request = new RevokeKeyRequestDto { Reason = "test" };
// Act
var response = await _client.PostAsJsonAsync(
$"/api/v1/anchors/{_testAnchorId}/keys/nonexistent-key/revoke",
request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task AddKey_InvalidAlgorithm_Returns400()
{
// Arrange
var request = new AddKeyRequestDto
{
KeyId = "bad-algo-key",
PublicKey = TestKeys.Ed25519PublicKeyPem,
Algorithm = "UNKNOWN-ALG"
};
// Act
var response = await _client.PostAsJsonAsync(
$"/api/v1/anchors/{_testAnchorId}/keys",
request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
#endregion
}
/// <summary>
/// Test key material.
/// </summary>
internal static class TestKeys
{
// Test Ed25519 public key (not for production use)
public const string Ed25519PublicKeyPem = """
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAGb9F2CMC7IaKG1svU1lN3Rjzk6uqO1l8dSEIAKDU8g0=
-----END PUBLIC KEY-----
""";
}

View File

@@ -0,0 +1,657 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using StellaOps.Signer.KeyManagement;
using StellaOps.Signer.KeyManagement.Entities;
using Xunit;
namespace StellaOps.Signer.Tests.KeyManagement;
/// <summary>
/// Unit tests for KeyRotationService.
/// Tests tasks PROOF-KEY-0003 through PROOF-KEY-0006.
/// </summary>
public class KeyRotationServiceTests : IDisposable
{
private readonly KeyManagementDbContext _dbContext;
private readonly KeyRotationService _service;
private readonly FakeTimeProvider _timeProvider;
public KeyRotationServiceTests()
{
var options = new DbContextOptionsBuilder<KeyManagementDbContext>()
.UseInMemoryDatabase(databaseName: $"TestDb_{Guid.NewGuid()}")
.Options;
_dbContext = new KeyManagementDbContext(options);
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero));
_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);
}
private async Task<TrustAnchorEntity> CreateTestAnchorAsync(
string purlPattern = "pkg:npm/*",
IList<string>? allowedKeyIds = null,
IList<string>? revokedKeyIds = null)
{
var anchor = new TrustAnchorEntity
{
AnchorId = Guid.NewGuid(),
PurlPattern = purlPattern,
AllowedKeyIds = allowedKeyIds ?? [],
RevokedKeyIds = revokedKeyIds ?? [],
IsActive = true,
CreatedAt = _timeProvider.GetUtcNow(),
UpdatedAt = _timeProvider.GetUtcNow()
};
_dbContext.TrustAnchors.Add(anchor);
await _dbContext.SaveChangesAsync();
return anchor;
}
#region AddKeyAsync Tests (PROOF-KEY-0003)
[Fact]
public async Task AddKeyAsync_NewKey_UpdatesAllowedKeyIds()
{
// Arrange
var anchor = await CreateTestAnchorAsync(allowedKeyIds: ["key-1"]);
// Act
var result = await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-2",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
// Assert
result.Success.Should().BeTrue();
result.AllowedKeyIds.Should().Contain("key-2");
result.AllowedKeyIds.Should().Contain("key-1");
result.AuditLogId.Should().NotBeNull();
}
[Fact]
public async Task AddKeyAsync_DuplicateKey_ReturnsError()
{
// Arrange
var anchor = await CreateTestAnchorAsync(allowedKeyIds: ["key-1"]);
// Add the key first
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-dup",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
// Act - try to add same key again
var result = await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-dup",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest2\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
// Assert
result.Success.Should().BeFalse();
result.ErrorMessage.Should().Contain("already exists");
}
[Fact]
public async Task AddKeyAsync_NonExistentAnchor_ReturnsError()
{
// Act
var result = await _service.AddKeyAsync(Guid.NewGuid(), new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
// Assert
result.Success.Should().BeFalse();
result.ErrorMessage.Should().Contain("not found");
}
[Fact]
public async Task AddKeyAsync_CreatesKeyHistory()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
// Act
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519",
ExpiresAt = _timeProvider.GetUtcNow().AddDays(365)
});
// Assert
var keyHistory = await _dbContext.KeyHistory
.FirstOrDefaultAsync(k => k.AnchorId == anchor.AnchorId && k.KeyId == "key-1");
keyHistory.Should().NotBeNull();
keyHistory!.Algorithm.Should().Be("Ed25519");
keyHistory.ExpiresAt.Should().NotBeNull();
}
[Fact]
public async Task AddKeyAsync_CreatesAuditLog()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
// Act
var result = await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
// Assert
var auditLog = await _dbContext.KeyAuditLog
.FirstOrDefaultAsync(a => a.LogId == result.AuditLogId);
auditLog.Should().NotBeNull();
auditLog!.Operation.Should().Be(KeyOperation.Add);
auditLog.KeyId.Should().Be("key-1");
auditLog.Actor.Should().Be("test-user");
}
#endregion
#region RevokeKeyAsync Tests (PROOF-KEY-0004)
[Fact]
public async Task RevokeKeyAsync_ExistingKey_MovesToRevokedKeys()
{
// Arrange
var anchor = await CreateTestAnchorAsync(allowedKeyIds: ["key-1", "key-2"]);
// Add key to history
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
// Act
var result = await _service.RevokeKeyAsync(anchor.AnchorId, "key-1", new RevokeKeyRequest
{
Reason = "rotation-complete"
});
// Assert
result.Success.Should().BeTrue();
result.AllowedKeyIds.Should().NotContain("key-1");
result.RevokedKeyIds.Should().Contain("key-1");
result.AuditLogId.Should().NotBeNull();
}
[Fact]
public async Task RevokeKeyAsync_AlreadyRevoked_ReturnsError()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
await _service.RevokeKeyAsync(anchor.AnchorId, "key-1", new RevokeKeyRequest
{
Reason = "first-revocation"
});
// Act
var result = await _service.RevokeKeyAsync(anchor.AnchorId, "key-1", new RevokeKeyRequest
{
Reason = "second-revocation"
});
// Assert
result.Success.Should().BeFalse();
result.ErrorMessage.Should().Contain("already revoked");
}
[Fact]
public async Task RevokeKeyAsync_NonExistentKey_ReturnsError()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
// Act
var result = await _service.RevokeKeyAsync(anchor.AnchorId, "non-existent", new RevokeKeyRequest
{
Reason = "test"
});
// Assert
result.Success.Should().BeFalse();
result.ErrorMessage.Should().Contain("not found");
}
[Fact]
public async Task RevokeKeyAsync_SetsRevokedAtTime()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
var effectiveAt = _timeProvider.GetUtcNow().AddDays(7);
// Act
await _service.RevokeKeyAsync(anchor.AnchorId, "key-1", new RevokeKeyRequest
{
Reason = "scheduled-rotation",
EffectiveAt = effectiveAt
});
// Assert
var keyHistory = await _dbContext.KeyHistory
.FirstOrDefaultAsync(k => k.KeyId == "key-1");
keyHistory!.RevokedAt.Should().Be(effectiveAt);
keyHistory.RevokeReason.Should().Be("scheduled-rotation");
}
#endregion
#region CheckKeyValidityAsync Tests (PROOF-KEY-0005)
[Fact]
public async Task CheckKeyValidityAsync_ActiveKey_IsValid()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
var signedAt = _timeProvider.GetUtcNow().AddHours(1);
// Act
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-1", signedAt);
// Assert
result.IsValid.Should().BeTrue();
result.Status.Should().Be(KeyStatus.Active);
}
[Fact]
public async Task CheckKeyValidityAsync_RevokedKeyBeforeRevocation_IsValid()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
// Add key at T0
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
var addedAt = _timeProvider.GetUtcNow();
// Advance time and revoke at T+10 days
_timeProvider.Advance(TimeSpan.FromDays(10));
await _service.RevokeKeyAsync(anchor.AnchorId, "key-1", new RevokeKeyRequest
{
Reason = "rotation"
});
// Check validity at T+5 days (before revocation)
var signedAt = addedAt.AddDays(5);
// Act
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-1", signedAt);
// Assert
result.IsValid.Should().BeTrue();
result.Status.Should().Be(KeyStatus.Revoked); // Key is revoked now but was valid at signedAt
}
[Fact]
public async Task CheckKeyValidityAsync_RevokedKeyAfterRevocation_IsInvalid()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
// Revoke immediately
await _service.RevokeKeyAsync(anchor.AnchorId, "key-1", new RevokeKeyRequest
{
Reason = "compromised"
});
// Try to verify signature made after revocation
var signedAt = _timeProvider.GetUtcNow().AddHours(1);
// Act
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-1", signedAt);
// Assert
result.IsValid.Should().BeFalse();
result.Status.Should().Be(KeyStatus.Revoked);
result.InvalidReason.Should().Contain("revoked");
}
[Fact]
public async Task CheckKeyValidityAsync_KeyNotYetValid_IsInvalid()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
// Try to verify signature made before key was added
var signedAt = _timeProvider.GetUtcNow().AddDays(-1);
// Act
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-1", signedAt);
// Assert
result.IsValid.Should().BeFalse();
result.Status.Should().Be(KeyStatus.NotYetValid);
}
[Fact]
public async Task CheckKeyValidityAsync_ExpiredKey_IsInvalid()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
var expiresAt = _timeProvider.GetUtcNow().AddDays(30);
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519",
ExpiresAt = expiresAt
});
// Try to verify signature made after expiry
var signedAt = expiresAt.AddDays(1);
// Act
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "key-1", signedAt);
// Assert
result.IsValid.Should().BeFalse();
result.Status.Should().Be(KeyStatus.Expired);
}
[Fact]
public async Task CheckKeyValidityAsync_UnknownKey_IsInvalid()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
// Act
var result = await _service.CheckKeyValidityAsync(anchor.AnchorId, "unknown-key", _timeProvider.GetUtcNow());
// Assert
result.IsValid.Should().BeFalse();
result.Status.Should().Be(KeyStatus.Unknown);
}
#endregion
#region GetRotationWarningsAsync Tests (PROOF-KEY-0006)
[Fact]
public async Task GetRotationWarningsAsync_ExpiringKey_ReturnsWarning()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
var expiresAt = _timeProvider.GetUtcNow().AddDays(30); // Within 60-day warning window
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "expiring-key",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519",
ExpiresAt = expiresAt
});
// Act
var warnings = await _service.GetRotationWarningsAsync(anchor.AnchorId);
// Assert
warnings.Should().ContainSingle();
warnings[0].KeyId.Should().Be("expiring-key");
warnings[0].WarningType.Should().Be(RotationWarningType.ExpiryApproaching);
warnings[0].CriticalAt.Should().Be(expiresAt);
}
[Fact]
public async Task GetRotationWarningsAsync_ExpiredKey_ReturnsWarning()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
var expiresAt = _timeProvider.GetUtcNow().AddDays(-1); // Already expired
_dbContext.KeyHistory.Add(new KeyHistoryEntity
{
HistoryId = Guid.NewGuid(),
AnchorId = anchor.AnchorId,
KeyId = "expired-key",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519",
AddedAt = _timeProvider.GetUtcNow().AddDays(-30),
ExpiresAt = expiresAt,
CreatedAt = _timeProvider.GetUtcNow().AddDays(-30)
});
await _dbContext.SaveChangesAsync();
// Act
var warnings = await _service.GetRotationWarningsAsync(anchor.AnchorId);
// Assert
warnings.Should().Contain(w => w.KeyId == "expired-key" && w.WarningType == RotationWarningType.ExpiryApproaching);
}
[Fact]
public async Task GetRotationWarningsAsync_LongLivedKey_ReturnsWarning()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
// Key added 400 days ago (exceeds 365-day max)
_dbContext.KeyHistory.Add(new KeyHistoryEntity
{
HistoryId = Guid.NewGuid(),
AnchorId = anchor.AnchorId,
KeyId = "old-key",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519",
AddedAt = _timeProvider.GetUtcNow().AddDays(-400),
CreatedAt = _timeProvider.GetUtcNow().AddDays(-400)
});
await _dbContext.SaveChangesAsync();
// Act
var warnings = await _service.GetRotationWarningsAsync(anchor.AnchorId);
// Assert
warnings.Should().Contain(w => w.KeyId == "old-key" && w.WarningType == RotationWarningType.LongLived);
}
[Fact]
public async Task GetRotationWarningsAsync_DeprecatedAlgorithm_ReturnsWarning()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "weak-key",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "RSA-2048" // Deprecated algorithm
});
// Act
var warnings = await _service.GetRotationWarningsAsync(anchor.AnchorId);
// Assert
warnings.Should().Contain(w => w.KeyId == "weak-key" && w.WarningType == RotationWarningType.AlgorithmDeprecating);
}
[Fact]
public async Task GetRotationWarningsAsync_NoIssues_ReturnsEmpty()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "healthy-key",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519",
ExpiresAt = _timeProvider.GetUtcNow().AddDays(365) // Far in future
});
// Act
var warnings = await _service.GetRotationWarningsAsync(anchor.AnchorId);
// Assert
warnings.Should().BeEmpty();
}
[Fact]
public async Task GetRotationWarningsAsync_RevokedKeys_NotIncluded()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "revoked-key",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "RSA-2048" // Deprecated but revoked
});
await _service.RevokeKeyAsync(anchor.AnchorId, "revoked-key", new RevokeKeyRequest
{
Reason = "rotation"
});
// Act
var warnings = await _service.GetRotationWarningsAsync(anchor.AnchorId);
// Assert
warnings.Should().NotContain(w => w.KeyId == "revoked-key");
}
#endregion
#region GetKeyHistoryAsync Tests
[Fact]
public async Task GetKeyHistoryAsync_ReturnsOrderedHistory()
{
// Arrange
var anchor = await CreateTestAnchorAsync();
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest1\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
_timeProvider.Advance(TimeSpan.FromDays(1));
await _service.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-2",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest2\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
// Act
var history = await _service.GetKeyHistoryAsync(anchor.AnchorId);
// Assert
history.Should().HaveCount(2);
history[0].KeyId.Should().Be("key-2"); // Most recent first
history[1].KeyId.Should().Be("key-1");
}
#endregion
}
/// <summary>
/// Fake time provider for testing.
/// </summary>
internal sealed class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public FakeTimeProvider(DateTimeOffset initialTime)
{
_now = initialTime;
}
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
public void SetTime(DateTimeOffset time) => _now = time;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,503 @@
using System;
using System.Collections.Generic;
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>
/// Tests for TrustAnchorManager and PURL pattern matching.
/// Tests tasks PROOF-KEY-0008 (PURL pattern matching) and PROOF-KEY-0009 (signature verification).
/// </summary>
public class TrustAnchorManagerTests : IDisposable
{
private readonly KeyManagementDbContext _dbContext;
private readonly KeyRotationService _rotationService;
private readonly TrustAnchorManager _manager;
private readonly FakeTimeProvider _timeProvider;
public TrustAnchorManagerTests()
{
var options = new DbContextOptionsBuilder<KeyManagementDbContext>()
.UseInMemoryDatabase(databaseName: $"TestDb_{Guid.NewGuid()}")
.Options;
_dbContext = new KeyManagementDbContext(options);
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero));
_rotationService = new KeyRotationService(
_dbContext,
NullLogger<KeyRotationService>.Instance,
Options.Create(new KeyRotationOptions()),
_timeProvider);
_manager = new TrustAnchorManager(
_dbContext,
_rotationService,
NullLogger<TrustAnchorManager>.Instance,
_timeProvider);
}
public void Dispose()
{
_dbContext.Dispose();
GC.SuppressFinalize(this);
}
#region PURL Pattern Matching Tests (PROOF-KEY-0008)
[Theory]
[InlineData("pkg:npm/*", true)]
[InlineData("pkg:maven/org.apache/*", true)]
[InlineData("pkg:npm/lodash", true)]
[InlineData("pkg:pypi/requests@2.28.0", true)]
[InlineData("npm/*", false)] // Missing pkg: prefix
[InlineData("pkg:", false)] // Missing type
[InlineData("", false)]
[InlineData(null, false)]
public void IsValidPattern_ValidatesCorrectly(string? pattern, bool expected)
{
PurlPatternMatcher.IsValidPattern(pattern!).Should().Be(expected);
}
[Theory]
[InlineData("pkg:npm/*", "pkg:npm/lodash@4.17.21", true)]
[InlineData("pkg:npm/*", "pkg:npm/@scope/package@1.0.0", true)]
[InlineData("pkg:npm/*", "pkg:pypi/requests@2.28.0", false)]
[InlineData("pkg:maven/org.apache/*", "pkg:maven/org.apache/commons-lang3@3.12.0", true)]
[InlineData("pkg:maven/org.apache/*", "pkg:maven/com.google/guava@31.0", false)]
[InlineData("pkg:npm/lodash", "pkg:npm/lodash", true)]
[InlineData("pkg:npm/lodash", "pkg:npm/lodash@4.17.21", false)] // Exact match only
[InlineData("pkg:npm/lodash*", "pkg:npm/lodash@4.17.21", true)] // Wildcard at end
public void Matches_EvaluatesCorrectly(string pattern, string purl, bool expected)
{
PurlPatternMatcher.Matches(pattern, purl).Should().Be(expected);
}
[Theory]
[InlineData("pkg:npm/*", 15)] // 2 segments * 10 - 1 wildcard * 5 = 15
[InlineData("pkg:maven/org.apache/*", 25)] // 3 segments * 10 - 1 wildcard * 5 = 25
[InlineData("pkg:npm/lodash", 20)] // 2 segments * 10 - 0 wildcards = 20
[InlineData("*", 5)] // 1 segment * 10 - 1 wildcard * 5 = 5
public void GetSpecificity_CalculatesCorrectly(string pattern, int expectedSpecificity)
{
PurlPatternMatcher.GetSpecificity(pattern).Should().Be(expectedSpecificity);
}
[Fact]
public async Task FindAnchorForPurl_SelectsMostSpecificMatch()
{
// Arrange - Create anchors with different specificity
await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = ["key-npm-general"]
});
await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/@myorg/*",
AllowedKeyIds = ["key-npm-myorg"]
});
await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/@myorg/specific-package*",
AllowedKeyIds = ["key-npm-specific"]
});
// Act & Assert - Most specific should be selected
var result1 = await _manager.FindAnchorForPurlAsync("pkg:npm/lodash@4.17.21");
result1.Should().NotBeNull();
result1!.AllowedKeyIds.Should().Contain("key-npm-general");
var result2 = await _manager.FindAnchorForPurlAsync("pkg:npm/@myorg/other-package@1.0.0");
result2.Should().NotBeNull();
result2!.AllowedKeyIds.Should().Contain("key-npm-myorg");
var result3 = await _manager.FindAnchorForPurlAsync("pkg:npm/@myorg/specific-package@2.0.0");
result3.Should().NotBeNull();
result3!.AllowedKeyIds.Should().Contain("key-npm-specific");
}
[Fact]
public async Task FindAnchorForPurl_NoMatch_ReturnsNull()
{
// Arrange
await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = ["key-1"]
});
// Act
var result = await _manager.FindAnchorForPurlAsync("pkg:maven/org.apache/commons@3.0");
// Assert
result.Should().BeNull();
}
[Fact]
public async Task FindAnchorForPurl_InactiveAnchor_NotReturned()
{
// Arrange
var anchor = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = ["key-1"]
});
await _manager.DeactivateAnchorAsync(anchor.AnchorId);
// Act
var result = await _manager.FindAnchorForPurlAsync("pkg:npm/lodash@4.17.21");
// Assert
result.Should().BeNull();
}
#endregion
#region Signature Verification with Key History Tests (PROOF-KEY-0009)
[Fact]
public async Task VerifySignatureAuthorization_ValidKey_Succeeds()
{
// Arrange
var anchor = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = []
});
await _rotationService.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
var signedAt = _timeProvider.GetUtcNow().AddHours(1);
// Act
var result = await _manager.VerifySignatureAuthorizationAsync(
anchor.AnchorId, "key-1", signedAt);
// Assert
result.IsAuthorized.Should().BeTrue();
result.KeyStatus.Should().Be(KeyStatus.Active);
}
[Fact]
public async Task VerifySignatureAuthorization_RevokedKeyBeforeRevocation_Succeeds()
{
// Arrange
var anchor = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = []
});
await _rotationService.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
var signedAt = _timeProvider.GetUtcNow().AddHours(1);
// Advance time and revoke
_timeProvider.Advance(TimeSpan.FromDays(30));
await _rotationService.RevokeKeyAsync(anchor.AnchorId, "key-1", new RevokeKeyRequest
{
Reason = "rotation"
});
// Act - Verify signature made before revocation
var result = await _manager.VerifySignatureAuthorizationAsync(
anchor.AnchorId, "key-1", signedAt);
// Assert - Should succeed because signature was made before revocation
result.IsAuthorized.Should().BeTrue();
result.KeyStatus.Should().Be(KeyStatus.Revoked); // Key is revoked now
}
[Fact]
public async Task VerifySignatureAuthorization_RevokedKeyAfterRevocation_Fails()
{
// Arrange
var anchor = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = []
});
await _rotationService.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
// Revoke immediately
await _rotationService.RevokeKeyAsync(anchor.AnchorId, "key-1", new RevokeKeyRequest
{
Reason = "compromised"
});
// Try to verify signature made after revocation
var signedAt = _timeProvider.GetUtcNow().AddHours(1);
// Act
var result = await _manager.VerifySignatureAuthorizationAsync(
anchor.AnchorId, "key-1", signedAt);
// Assert
result.IsAuthorized.Should().BeFalse();
result.KeyStatus.Should().Be(KeyStatus.Revoked);
result.FailureReason.Should().Contain("revoked");
}
[Fact]
public async Task VerifySignatureAuthorization_UnknownKey_Fails()
{
// Arrange
var anchor = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = []
});
// Act
var result = await _manager.VerifySignatureAuthorizationAsync(
anchor.AnchorId, "unknown-key", _timeProvider.GetUtcNow());
// Assert
result.IsAuthorized.Should().BeFalse();
result.KeyStatus.Should().Be(KeyStatus.Unknown);
}
[Fact]
public async Task VerifySignatureAuthorization_PredicateTypeAllowed_Succeeds()
{
// Arrange
var anchor = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = [],
AllowedPredicateTypes = ["evidence.stella/v1", "reasoning.stella/v1"]
});
await _rotationService.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
// Act
var result = await _manager.VerifySignatureAuthorizationAsync(
anchor.AnchorId, "key-1", _timeProvider.GetUtcNow().AddHours(1), "evidence.stella/v1");
// Assert
result.IsAuthorized.Should().BeTrue();
result.PredicateTypeAllowed.Should().BeTrue();
}
[Fact]
public async Task VerifySignatureAuthorization_PredicateTypeNotAllowed_Fails()
{
// Arrange
var anchor = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = [],
AllowedPredicateTypes = ["evidence.stella/v1"] // Only evidence allowed
});
await _rotationService.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
// Act
var result = await _manager.VerifySignatureAuthorizationAsync(
anchor.AnchorId, "key-1", _timeProvider.GetUtcNow().AddHours(1), "vex.stella/v1");
// Assert
result.IsAuthorized.Should().BeFalse();
result.PredicateTypeAllowed.Should().BeFalse();
result.FailureReason.Should().Contain("not allowed");
}
[Fact]
public async Task VerifySignatureAuthorization_NoPredicateRestriction_AllAllowed()
{
// Arrange - No AllowedPredicateTypes means all are allowed
var anchor = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = [],
AllowedPredicateTypes = null
});
await _rotationService.AddKeyAsync(anchor.AnchorId, new AddKeyRequest
{
KeyId = "key-1",
PublicKey = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
Algorithm = "Ed25519"
});
// Act
var result = await _manager.VerifySignatureAuthorizationAsync(
anchor.AnchorId, "key-1", _timeProvider.GetUtcNow().AddHours(1), "any.predicate/v1");
// Assert
result.IsAuthorized.Should().BeTrue();
result.PredicateTypeAllowed.Should().BeTrue();
}
#endregion
#region CRUD Operations Tests
[Fact]
public async Task CreateAnchor_ValidRequest_Succeeds()
{
// Act
var anchor = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = ["key-1", "key-2"],
AllowedPredicateTypes = ["evidence.stella/v1"],
PolicyRef = "policy-001",
PolicyVersion = "v1.0"
});
// Assert
anchor.Should().NotBeNull();
anchor.AnchorId.Should().NotBeEmpty();
anchor.PurlPattern.Should().Be("pkg:npm/*");
anchor.AllowedKeyIds.Should().Contain(["key-1", "key-2"]);
anchor.AllowedPredicateTypes.Should().Contain("evidence.stella/v1");
anchor.IsActive.Should().BeTrue();
}
[Fact]
public async Task GetAnchor_Exists_ReturnsAnchor()
{
// Arrange
var created = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = ["key-1"]
});
// Act
var anchor = await _manager.GetAnchorAsync(created.AnchorId);
// Assert
anchor.Should().NotBeNull();
anchor!.AnchorId.Should().Be(created.AnchorId);
}
[Fact]
public async Task GetAnchor_NotExists_ReturnsNull()
{
// Act
var anchor = await _manager.GetAnchorAsync(Guid.NewGuid());
// Assert
anchor.Should().BeNull();
}
[Fact]
public async Task UpdateAnchor_ValidRequest_UpdatesFields()
{
// Arrange
var created = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = [],
PolicyVersion = "v1.0"
});
// Act
var updated = await _manager.UpdateAnchorAsync(created.AnchorId, new UpdateTrustAnchorRequest
{
PolicyVersion = "v2.0",
AllowedPredicateTypes = ["new.predicate/v1"]
});
// Assert
updated.PolicyVersion.Should().Be("v2.0");
updated.AllowedPredicateTypes.Should().Contain("new.predicate/v1");
updated.UpdatedAt.Should().BeAfter(created.CreatedAt);
}
[Fact]
public async Task DeactivateAnchor_SetsInactive()
{
// Arrange
var created = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = []
});
// Act
await _manager.DeactivateAnchorAsync(created.AnchorId);
// Assert
var anchor = await _manager.GetAnchorAsync(created.AnchorId);
anchor!.IsActive.Should().BeFalse();
}
[Fact]
public async Task GetActiveAnchors_ReturnsOnlyActive()
{
// Arrange
var active1 = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:npm/*",
AllowedKeyIds = []
});
var inactive = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:pypi/*",
AllowedKeyIds = []
});
await _manager.DeactivateAnchorAsync(inactive.AnchorId);
var active2 = await _manager.CreateAnchorAsync(new CreateTrustAnchorRequest
{
PurlPattern = "pkg:maven/*",
AllowedKeyIds = []
});
// Act
var anchors = await _manager.GetActiveAnchorsAsync();
// Assert
anchors.Should().HaveCount(2);
anchors.Should().Contain(a => a.AnchorId == active1.AnchorId);
anchors.Should().Contain(a => a.AnchorId == active2.AnchorId);
anchors.Should().NotContain(a => a.AnchorId == inactive.AnchorId);
}
#endregion
}

View File

@@ -11,6 +11,7 @@
<ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.0-preview.7.24407.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
@@ -22,6 +23,7 @@
<ProjectReference Include="..\StellaOps.Signer.WebService\StellaOps.Signer.WebService.csproj" />
<ProjectReference Include="..\StellaOps.Signer.Infrastructure\StellaOps.Signer.Infrastructure.csproj" />
<ProjectReference Include="..\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Signer.KeyManagement\StellaOps.Signer.KeyManagement.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />