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,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-----
|
||||
""";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user