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" />
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// KeyRotationEndpoints.cs
|
||||
// Sprint: SPRINT_0501_0008_0001_proof_chain_key_rotation
|
||||
// Task: PROOF-KEY-0010 - Implement key rotation API endpoints
|
||||
// Description: API endpoints for key rotation and trust anchor management
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Signer.KeyManagement;
|
||||
|
||||
namespace StellaOps.Signer.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for key rotation operations.
|
||||
/// Implements advisory §8.2 key rotation workflow.
|
||||
/// </summary>
|
||||
public static class KeyRotationEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Map key rotation endpoints to the router.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapKeyRotationEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v1/anchors")
|
||||
.WithTags("KeyRotation", "TrustAnchors")
|
||||
.RequireAuthorization("KeyManagement");
|
||||
|
||||
// Key management endpoints
|
||||
group.MapPost("/{anchorId:guid}/keys", AddKeyAsync)
|
||||
.WithName("AddKey")
|
||||
.WithSummary("Add a new signing key to a trust anchor")
|
||||
.Produces<AddKeyResponseDto>(StatusCodes.Status201Created)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{anchorId:guid}/keys/{keyId}/revoke", RevokeKeyAsync)
|
||||
.WithName("RevokeKey")
|
||||
.WithSummary("Revoke a signing key from a trust anchor")
|
||||
.Produces<RevokeKeyResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/{anchorId:guid}/keys/{keyId}/validity", CheckKeyValidityAsync)
|
||||
.WithName("CheckKeyValidity")
|
||||
.WithSummary("Check if a key was valid at a specific time")
|
||||
.Produces<KeyValidityResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/{anchorId:guid}/keys/history", GetKeyHistoryAsync)
|
||||
.WithName("GetKeyHistory")
|
||||
.WithSummary("Get the full key history for a trust anchor")
|
||||
.Produces<KeyHistoryResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/{anchorId:guid}/keys/warnings", GetRotationWarningsAsync)
|
||||
.WithName("GetRotationWarnings")
|
||||
.WithSummary("Get rotation warnings for a trust anchor")
|
||||
.Produces<RotationWarningsResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a new signing key to a trust anchor.
|
||||
/// </summary>
|
||||
private static async Task<IResult> AddKeyAsync(
|
||||
[FromRoute] Guid anchorId,
|
||||
[FromBody] AddKeyRequestDto request,
|
||||
IKeyRotationService rotationService,
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger("KeyRotationEndpoints.AddKey");
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Invalid request",
|
||||
detail: "Request body is required.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var addRequest = new AddKeyRequest
|
||||
{
|
||||
KeyId = request.KeyId,
|
||||
PublicKey = request.PublicKey,
|
||||
Algorithm = request.Algorithm,
|
||||
ExpiresAt = request.ExpiresAt,
|
||||
Metadata = request.Metadata
|
||||
};
|
||||
|
||||
var result = await rotationService.AddKeyAsync(anchorId, addRequest, ct);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Key addition failed",
|
||||
detail: result.ErrorMessage,
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Added key {KeyId} to anchor {AnchorId}, audit log {AuditLogId}",
|
||||
request.KeyId, anchorId, result.AuditLogId);
|
||||
|
||||
var response = new AddKeyResponseDto
|
||||
{
|
||||
KeyId = request.KeyId,
|
||||
AnchorId = anchorId,
|
||||
AllowedKeyIds = result.AllowedKeyIds.ToList(),
|
||||
AuditLogId = result.AuditLogId
|
||||
};
|
||||
|
||||
return Results.Created($"/api/v1/anchors/{anchorId}/keys/{request.KeyId}", response);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Anchor not found",
|
||||
detail: $"Trust anchor {anchorId} not found.",
|
||||
statusCode: StatusCodes.Status404NotFound);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to add key {KeyId} to anchor {AnchorId}", request.KeyId, anchorId);
|
||||
return Results.Problem(
|
||||
title: "Internal error",
|
||||
detail: "An unexpected error occurred.",
|
||||
statusCode: StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revoke a signing key from a trust anchor.
|
||||
/// </summary>
|
||||
private static async Task<IResult> RevokeKeyAsync(
|
||||
[FromRoute] Guid anchorId,
|
||||
[FromRoute] string keyId,
|
||||
[FromBody] RevokeKeyRequestDto request,
|
||||
IKeyRotationService rotationService,
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger("KeyRotationEndpoints.RevokeKey");
|
||||
|
||||
if (request is null || string.IsNullOrWhiteSpace(request.Reason))
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Invalid request",
|
||||
detail: "Revocation reason is required.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var revokeRequest = new RevokeKeyRequest
|
||||
{
|
||||
Reason = request.Reason,
|
||||
EffectiveAt = request.EffectiveAt
|
||||
};
|
||||
|
||||
var result = await rotationService.RevokeKeyAsync(anchorId, keyId, revokeRequest, ct);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Key revocation failed",
|
||||
detail: result.ErrorMessage,
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Revoked key {KeyId} from anchor {AnchorId}, reason: {Reason}, audit log {AuditLogId}",
|
||||
keyId, anchorId, request.Reason, result.AuditLogId);
|
||||
|
||||
var response = new RevokeKeyResponseDto
|
||||
{
|
||||
KeyId = keyId,
|
||||
AnchorId = anchorId,
|
||||
RevokedAt = request.EffectiveAt ?? DateTimeOffset.UtcNow,
|
||||
Reason = request.Reason,
|
||||
AllowedKeyIds = result.AllowedKeyIds.ToList(),
|
||||
RevokedKeyIds = result.RevokedKeyIds.ToList(),
|
||||
AuditLogId = result.AuditLogId
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Key or anchor not found",
|
||||
detail: $"Trust anchor {anchorId} or key {keyId} not found.",
|
||||
statusCode: StatusCodes.Status404NotFound);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to revoke key {KeyId} from anchor {AnchorId}", keyId, anchorId);
|
||||
return Results.Problem(
|
||||
title: "Internal error",
|
||||
detail: "An unexpected error occurred.",
|
||||
statusCode: StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a key was valid at a specific time.
|
||||
/// </summary>
|
||||
private static async Task<IResult> CheckKeyValidityAsync(
|
||||
[FromRoute] Guid anchorId,
|
||||
[FromRoute] string keyId,
|
||||
[FromQuery] DateTimeOffset? signedAt,
|
||||
IKeyRotationService rotationService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var checkTime = signedAt ?? DateTimeOffset.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await rotationService.CheckKeyValidityAsync(anchorId, keyId, checkTime, ct);
|
||||
|
||||
var response = new KeyValidityResponseDto
|
||||
{
|
||||
KeyId = keyId,
|
||||
AnchorId = anchorId,
|
||||
CheckedAt = checkTime,
|
||||
IsValid = result.IsValid,
|
||||
Status = result.Status.ToString(),
|
||||
AddedAt = result.AddedAt,
|
||||
RevokedAt = result.RevokedAt,
|
||||
InvalidReason = result.InvalidReason
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Key or anchor not found",
|
||||
detail: $"Trust anchor {anchorId} or key {keyId} not found.",
|
||||
statusCode: StatusCodes.Status404NotFound);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the full key history for a trust anchor.
|
||||
/// </summary>
|
||||
private static async Task<IResult> GetKeyHistoryAsync(
|
||||
[FromRoute] Guid anchorId,
|
||||
IKeyRotationService rotationService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var history = await rotationService.GetKeyHistoryAsync(anchorId, ct);
|
||||
|
||||
var response = new KeyHistoryResponseDto
|
||||
{
|
||||
AnchorId = anchorId,
|
||||
Entries = history.Select(e => new KeyHistoryEntryDto
|
||||
{
|
||||
KeyId = e.KeyId,
|
||||
Algorithm = e.Algorithm,
|
||||
AddedAt = e.AddedAt,
|
||||
RevokedAt = e.RevokedAt,
|
||||
RevokeReason = e.RevokeReason,
|
||||
ExpiresAt = e.ExpiresAt
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Anchor not found",
|
||||
detail: $"Trust anchor {anchorId} not found.",
|
||||
statusCode: StatusCodes.Status404NotFound);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get rotation warnings for a trust anchor.
|
||||
/// </summary>
|
||||
private static async Task<IResult> GetRotationWarningsAsync(
|
||||
[FromRoute] Guid anchorId,
|
||||
IKeyRotationService rotationService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var warnings = await rotationService.GetRotationWarningsAsync(anchorId, ct);
|
||||
|
||||
var response = new RotationWarningsResponseDto
|
||||
{
|
||||
AnchorId = anchorId,
|
||||
Warnings = warnings.Select(w => new RotationWarningDto
|
||||
{
|
||||
KeyId = w.KeyId,
|
||||
WarningType = w.WarningType.ToString(),
|
||||
Message = w.Message,
|
||||
CriticalAt = w.CriticalAt
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Anchor not found",
|
||||
detail: $"Trust anchor {anchorId} not found.",
|
||||
statusCode: StatusCodes.Status404NotFound);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#region Request/Response DTOs
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for adding a key.
|
||||
/// </summary>
|
||||
public sealed record AddKeyRequestDto
|
||||
{
|
||||
[Required]
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string PublicKey { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string Algorithm { get; init; }
|
||||
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for adding a key.
|
||||
/// </summary>
|
||||
public sealed record AddKeyResponseDto
|
||||
{
|
||||
public required string KeyId { get; init; }
|
||||
public required Guid AnchorId { get; init; }
|
||||
public required List<string> AllowedKeyIds { get; init; }
|
||||
public Guid? AuditLogId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for revoking a key.
|
||||
/// </summary>
|
||||
public sealed record RevokeKeyRequestDto
|
||||
{
|
||||
[Required]
|
||||
public required string Reason { get; init; }
|
||||
|
||||
public DateTimeOffset? EffectiveAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for revoking a key.
|
||||
/// </summary>
|
||||
public sealed record RevokeKeyResponseDto
|
||||
{
|
||||
public required string KeyId { get; init; }
|
||||
public required Guid AnchorId { get; init; }
|
||||
public required DateTimeOffset RevokedAt { get; init; }
|
||||
public required string Reason { get; init; }
|
||||
public required List<string> AllowedKeyIds { get; init; }
|
||||
public required List<string> RevokedKeyIds { get; init; }
|
||||
public Guid? AuditLogId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for key validity check.
|
||||
/// </summary>
|
||||
public sealed record KeyValidityResponseDto
|
||||
{
|
||||
public required string KeyId { get; init; }
|
||||
public required Guid AnchorId { get; init; }
|
||||
public required DateTimeOffset CheckedAt { get; init; }
|
||||
public required bool IsValid { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required DateTimeOffset AddedAt { get; init; }
|
||||
public DateTimeOffset? RevokedAt { get; init; }
|
||||
public string? InvalidReason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for key history.
|
||||
/// </summary>
|
||||
public sealed record KeyHistoryResponseDto
|
||||
{
|
||||
public required Guid AnchorId { get; init; }
|
||||
public required List<KeyHistoryEntryDto> Entries { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO for a key history entry.
|
||||
/// </summary>
|
||||
public sealed record KeyHistoryEntryDto
|
||||
{
|
||||
public required string KeyId { get; init; }
|
||||
public required string Algorithm { get; init; }
|
||||
public required DateTimeOffset AddedAt { get; init; }
|
||||
public DateTimeOffset? RevokedAt { get; init; }
|
||||
public string? RevokeReason { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for rotation warnings.
|
||||
/// </summary>
|
||||
public sealed record RotationWarningsResponseDto
|
||||
{
|
||||
public required Guid AnchorId { get; init; }
|
||||
public required List<RotationWarningDto> Warnings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO for a rotation warning.
|
||||
/// </summary>
|
||||
public sealed record RotationWarningDto
|
||||
{
|
||||
public required string KeyId { get; init; }
|
||||
public required string WarningType { get; init; }
|
||||
public required string Message { get; init; }
|
||||
public DateTimeOffset? CriticalAt { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -137,6 +137,18 @@ public class KeyAuditLogEntity
|
||||
[Column("new_state", TypeName = "jsonb")]
|
||||
public JsonDocument? NewState { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for the operation.
|
||||
/// </summary>
|
||||
[Column("reason")]
|
||||
public string? Reason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata about the operation.
|
||||
/// </summary>
|
||||
[Column("metadata", TypeName = "jsonb")]
|
||||
public JsonDocument? Metadata { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional details about the operation.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace StellaOps.Signer.KeyManagement.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Trust anchor entity.
|
||||
/// Maps to signer.trust_anchors table.
|
||||
/// </summary>
|
||||
[Table("trust_anchors", Schema = "signer")]
|
||||
public class TrustAnchorEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Primary key.
|
||||
/// </summary>
|
||||
[Key]
|
||||
[Column("anchor_id")]
|
||||
public Guid AnchorId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// PURL glob pattern (e.g., pkg:npm/*).
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("purl_pattern")]
|
||||
public string PurlPattern { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Currently allowed key IDs.
|
||||
/// </summary>
|
||||
[Column("allowed_key_ids", TypeName = "text[]")]
|
||||
public IList<string>? AllowedKeyIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Allowed predicate types (null = all).
|
||||
/// </summary>
|
||||
[Column("allowed_predicate_types", TypeName = "text[]")]
|
||||
public IList<string>? AllowedPredicateTypes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy reference.
|
||||
/// </summary>
|
||||
[Column("policy_ref")]
|
||||
public string? PolicyRef { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy version.
|
||||
/// </summary>
|
||||
[Column("policy_version")]
|
||||
public string? PolicyVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Revoked key IDs (still valid for historical proofs).
|
||||
/// </summary>
|
||||
[Column("revoked_key_ids", TypeName = "text[]")]
|
||||
public IList<string>? RevokedKeyIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the anchor is active.
|
||||
/// </summary>
|
||||
[Column("is_active")]
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When the anchor was created.
|
||||
/// </summary>
|
||||
[Column("created_at")]
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the anchor was last updated.
|
||||
/// </summary>
|
||||
[Column("updated_at")]
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Key operation types for audit logging.
|
||||
/// </summary>
|
||||
public static class KeyOperation
|
||||
{
|
||||
public const string Add = "add";
|
||||
public const string Revoke = "revoke";
|
||||
public const string Rotate = "rotate";
|
||||
public const string Update = "update";
|
||||
public const string Verify = "verify";
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
using StellaOps.Signer.KeyManagement.Entities;
|
||||
|
||||
namespace StellaOps.Signer.KeyManagement;
|
||||
|
||||
/// <summary>
|
||||
/// DbContext for key management entities.
|
||||
/// </summary>
|
||||
public class KeyManagementDbContext : DbContext
|
||||
{
|
||||
public KeyManagementDbContext(DbContextOptions<KeyManagementDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Key history entries.
|
||||
/// </summary>
|
||||
public DbSet<KeyHistoryEntity> KeyHistory => Set<KeyHistoryEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Key audit log entries.
|
||||
/// </summary>
|
||||
public DbSet<KeyAuditLogEntity> KeyAuditLog => Set<KeyAuditLogEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Trust anchors.
|
||||
/// </summary>
|
||||
public DbSet<TrustAnchorEntity> TrustAnchors => Set<TrustAnchorEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.HasDefaultSchema("signer");
|
||||
|
||||
modelBuilder.Entity<KeyHistoryEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.HistoryId);
|
||||
entity.HasIndex(e => new { e.AnchorId, e.KeyId }).IsUnique();
|
||||
entity.HasIndex(e => e.AnchorId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<KeyAuditLogEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.LogId);
|
||||
entity.HasIndex(e => e.AnchorId);
|
||||
entity.HasIndex(e => e.CreatedAt).IsDescending();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<TrustAnchorEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.AnchorId);
|
||||
entity.HasIndex(e => e.PurlPattern);
|
||||
entity.HasIndex(e => e.IsActive);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using StellaOps.Signer.KeyManagement.Entities;
|
||||
|
||||
namespace StellaOps.Signer.KeyManagement;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of key rotation service.
|
||||
/// Implements advisory §8.2 key rotation workflow with full audit logging.
|
||||
/// </summary>
|
||||
public sealed class KeyRotationService : IKeyRotationService
|
||||
{
|
||||
private readonly KeyManagementDbContext _dbContext;
|
||||
private readonly ILogger<KeyRotationService> _logger;
|
||||
private readonly KeyRotationOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public KeyRotationService(
|
||||
KeyManagementDbContext dbContext,
|
||||
ILogger<KeyRotationService> logger,
|
||||
IOptions<KeyRotationOptions> options,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? new KeyRotationOptions();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<KeyRotationResult> AddKeyAsync(
|
||||
Guid anchorId,
|
||||
AddKeyRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.KeyId))
|
||||
{
|
||||
return FailedResult("KeyId is required.", [], []);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.PublicKey))
|
||||
{
|
||||
return FailedResult("PublicKey is required.", [], []);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Algorithm))
|
||||
{
|
||||
return FailedResult("Algorithm is required.", [], []);
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
// Check if anchor exists
|
||||
var anchor = await _dbContext.TrustAnchors
|
||||
.FirstOrDefaultAsync(a => a.AnchorId == anchorId, ct);
|
||||
|
||||
if (anchor is null)
|
||||
{
|
||||
return FailedResult($"Trust anchor {anchorId} not found.", [], []);
|
||||
}
|
||||
|
||||
// Check if key already exists
|
||||
var existingKey = await _dbContext.KeyHistory
|
||||
.FirstOrDefaultAsync(k => k.AnchorId == anchorId && k.KeyId == request.KeyId, ct);
|
||||
|
||||
if (existingKey is not null)
|
||||
{
|
||||
return FailedResult($"Key {request.KeyId} already exists for anchor {anchorId}.", [], []);
|
||||
}
|
||||
|
||||
// Create key history entry
|
||||
var keyEntry = new KeyHistoryEntity
|
||||
{
|
||||
HistoryId = Guid.NewGuid(),
|
||||
AnchorId = anchorId,
|
||||
KeyId = request.KeyId,
|
||||
PublicKey = request.PublicKey,
|
||||
Algorithm = request.Algorithm,
|
||||
AddedAt = now,
|
||||
ExpiresAt = request.ExpiresAt,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
_dbContext.KeyHistory.Add(keyEntry);
|
||||
|
||||
// Update anchor's allowed key IDs
|
||||
var allowedKeys = anchor.AllowedKeyIds?.ToList() ?? [];
|
||||
allowedKeys.Add(request.KeyId);
|
||||
anchor.AllowedKeyIds = allowedKeys;
|
||||
anchor.UpdatedAt = now;
|
||||
|
||||
// Create audit log entry
|
||||
var auditEntry = new KeyAuditLogEntity
|
||||
{
|
||||
LogId = Guid.NewGuid(),
|
||||
AnchorId = anchorId,
|
||||
KeyId = request.KeyId,
|
||||
Operation = KeyOperation.Add,
|
||||
Actor = _options.DefaultActor,
|
||||
Reason = "Key added via rotation service",
|
||||
Metadata = null,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
_dbContext.KeyAuditLog.Add(auditEntry);
|
||||
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Added key {KeyId} to anchor {AnchorId}. Audit log: {AuditLogId}",
|
||||
request.KeyId, anchorId, auditEntry.LogId);
|
||||
|
||||
var revokedKeys = await GetRevokedKeyIdsAsync(anchorId, ct);
|
||||
|
||||
return new KeyRotationResult
|
||||
{
|
||||
Success = true,
|
||||
AllowedKeyIds = anchor.AllowedKeyIds,
|
||||
RevokedKeyIds = revokedKeys,
|
||||
AuditLogId = auditEntry.LogId
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
_logger.LogError(ex, "Failed to add key {KeyId} to anchor {AnchorId}", request.KeyId, anchorId);
|
||||
return FailedResult($"Failed to add key: {ex.Message}", [], []);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<KeyRotationResult> RevokeKeyAsync(
|
||||
Guid anchorId,
|
||||
string keyId,
|
||||
RevokeKeyRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(keyId))
|
||||
{
|
||||
return FailedResult("KeyId is required.", [], []);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Reason))
|
||||
{
|
||||
return FailedResult("Reason is required.", [], []);
|
||||
}
|
||||
|
||||
var effectiveAt = request.EffectiveAt ?? _timeProvider.GetUtcNow();
|
||||
|
||||
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
// Check if anchor exists
|
||||
var anchor = await _dbContext.TrustAnchors
|
||||
.FirstOrDefaultAsync(a => a.AnchorId == anchorId, ct);
|
||||
|
||||
if (anchor is null)
|
||||
{
|
||||
return FailedResult($"Trust anchor {anchorId} not found.", [], []);
|
||||
}
|
||||
|
||||
// Find the key in history
|
||||
var keyEntry = await _dbContext.KeyHistory
|
||||
.FirstOrDefaultAsync(k => k.AnchorId == anchorId && k.KeyId == keyId, ct);
|
||||
|
||||
if (keyEntry is null)
|
||||
{
|
||||
return FailedResult($"Key {keyId} not found for anchor {anchorId}.", [], []);
|
||||
}
|
||||
|
||||
if (keyEntry.RevokedAt is not null)
|
||||
{
|
||||
return FailedResult($"Key {keyId} is already revoked.", [], []);
|
||||
}
|
||||
|
||||
// Revoke the key
|
||||
keyEntry.RevokedAt = effectiveAt;
|
||||
keyEntry.RevokeReason = request.Reason;
|
||||
|
||||
// Remove from allowed keys
|
||||
var allowedKeys = anchor.AllowedKeyIds?.ToList() ?? [];
|
||||
allowedKeys.Remove(keyId);
|
||||
anchor.AllowedKeyIds = allowedKeys;
|
||||
|
||||
// Add to revoked keys
|
||||
var revokedKeys = anchor.RevokedKeyIds?.ToList() ?? [];
|
||||
revokedKeys.Add(keyId);
|
||||
anchor.RevokedKeyIds = revokedKeys;
|
||||
anchor.UpdatedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
// Create audit log entry
|
||||
var auditEntry = new KeyAuditLogEntity
|
||||
{
|
||||
LogId = Guid.NewGuid(),
|
||||
AnchorId = anchorId,
|
||||
KeyId = keyId,
|
||||
Operation = KeyOperation.Revoke,
|
||||
Actor = _options.DefaultActor,
|
||||
Reason = request.Reason,
|
||||
Metadata = null,
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
_dbContext.KeyAuditLog.Add(auditEntry);
|
||||
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Revoked key {KeyId} from anchor {AnchorId}. Reason: {Reason}. Audit log: {AuditLogId}",
|
||||
keyId, anchorId, request.Reason, auditEntry.LogId);
|
||||
|
||||
return new KeyRotationResult
|
||||
{
|
||||
Success = true,
|
||||
AllowedKeyIds = anchor.AllowedKeyIds,
|
||||
RevokedKeyIds = anchor.RevokedKeyIds,
|
||||
AuditLogId = auditEntry.LogId
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
_logger.LogError(ex, "Failed to revoke key {KeyId} from anchor {AnchorId}", keyId, anchorId);
|
||||
return FailedResult($"Failed to revoke key: {ex.Message}", [], []);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<KeyValidityResult> CheckKeyValidityAsync(
|
||||
Guid anchorId,
|
||||
string keyId,
|
||||
DateTimeOffset signedAt,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyId))
|
||||
{
|
||||
return new KeyValidityResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = KeyStatus.Unknown,
|
||||
AddedAt = DateTimeOffset.MinValue,
|
||||
InvalidReason = "KeyId is required."
|
||||
};
|
||||
}
|
||||
|
||||
// Find the key in history
|
||||
var keyEntry = await _dbContext.KeyHistory
|
||||
.FirstOrDefaultAsync(k => k.AnchorId == anchorId && k.KeyId == keyId, ct);
|
||||
|
||||
if (keyEntry is null)
|
||||
{
|
||||
return new KeyValidityResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = KeyStatus.Unknown,
|
||||
AddedAt = DateTimeOffset.MinValue,
|
||||
InvalidReason = $"Key {keyId} not found for anchor {anchorId}."
|
||||
};
|
||||
}
|
||||
|
||||
// Check temporal validity: was the key added before the signature was made?
|
||||
if (signedAt < keyEntry.AddedAt)
|
||||
{
|
||||
return new KeyValidityResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = KeyStatus.NotYetValid,
|
||||
AddedAt = keyEntry.AddedAt,
|
||||
RevokedAt = keyEntry.RevokedAt,
|
||||
InvalidReason = $"Key was added at {keyEntry.AddedAt:O}, but signature was made at {signedAt:O}."
|
||||
};
|
||||
}
|
||||
|
||||
// Check if key was revoked before signature
|
||||
if (keyEntry.RevokedAt.HasValue && signedAt >= keyEntry.RevokedAt.Value)
|
||||
{
|
||||
return new KeyValidityResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = KeyStatus.Revoked,
|
||||
AddedAt = keyEntry.AddedAt,
|
||||
RevokedAt = keyEntry.RevokedAt,
|
||||
InvalidReason = $"Key was revoked at {keyEntry.RevokedAt:O}, signature was made at {signedAt:O}."
|
||||
};
|
||||
}
|
||||
|
||||
// Check if key had expired before signature
|
||||
if (keyEntry.ExpiresAt.HasValue && signedAt >= keyEntry.ExpiresAt.Value)
|
||||
{
|
||||
return new KeyValidityResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = KeyStatus.Expired,
|
||||
AddedAt = keyEntry.AddedAt,
|
||||
RevokedAt = keyEntry.RevokedAt,
|
||||
InvalidReason = $"Key expired at {keyEntry.ExpiresAt:O}, signature was made at {signedAt:O}."
|
||||
};
|
||||
}
|
||||
|
||||
// Key is valid at the specified time
|
||||
var status = keyEntry.RevokedAt.HasValue
|
||||
? KeyStatus.Revoked // Revoked but valid for this historical signature
|
||||
: KeyStatus.Active;
|
||||
|
||||
return new KeyValidityResult
|
||||
{
|
||||
IsValid = true,
|
||||
Status = status,
|
||||
AddedAt = keyEntry.AddedAt,
|
||||
RevokedAt = keyEntry.RevokedAt
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<KeyRotationWarning>> GetRotationWarningsAsync(
|
||||
Guid anchorId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var warnings = new List<KeyRotationWarning>();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Get all active (non-revoked) keys for the anchor
|
||||
var activeKeys = await _dbContext.KeyHistory
|
||||
.Where(k => k.AnchorId == anchorId && k.RevokedAt == null)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var key in activeKeys)
|
||||
{
|
||||
// Check for expiry approaching
|
||||
if (key.ExpiresAt.HasValue)
|
||||
{
|
||||
var daysUntilExpiry = (key.ExpiresAt.Value - now).TotalDays;
|
||||
|
||||
if (daysUntilExpiry <= 0)
|
||||
{
|
||||
warnings.Add(new KeyRotationWarning
|
||||
{
|
||||
KeyId = key.KeyId,
|
||||
WarningType = RotationWarningType.ExpiryApproaching,
|
||||
Message = $"Key {key.KeyId} has expired on {key.ExpiresAt:O}.",
|
||||
CriticalAt = key.ExpiresAt
|
||||
});
|
||||
}
|
||||
else if (daysUntilExpiry <= _options.ExpiryWarningDays)
|
||||
{
|
||||
warnings.Add(new KeyRotationWarning
|
||||
{
|
||||
KeyId = key.KeyId,
|
||||
WarningType = RotationWarningType.ExpiryApproaching,
|
||||
Message = $"Key {key.KeyId} expires in {daysUntilExpiry:F0} days on {key.ExpiresAt:O}.",
|
||||
CriticalAt = key.ExpiresAt
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for long-lived keys
|
||||
var keyAge = now - key.AddedAt;
|
||||
if (keyAge.TotalDays > _options.MaxKeyAgeDays)
|
||||
{
|
||||
warnings.Add(new KeyRotationWarning
|
||||
{
|
||||
KeyId = key.KeyId,
|
||||
WarningType = RotationWarningType.LongLived,
|
||||
Message = $"Key {key.KeyId} has been active for {keyAge.TotalDays:F0} days. Consider rotation.",
|
||||
CriticalAt = key.AddedAt.AddDays(_options.MaxKeyAgeDays + _options.ExpiryWarningDays)
|
||||
});
|
||||
}
|
||||
|
||||
// Check for deprecated algorithms
|
||||
if (_options.DeprecatedAlgorithms.Contains(key.Algorithm, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
warnings.Add(new KeyRotationWarning
|
||||
{
|
||||
KeyId = key.KeyId,
|
||||
WarningType = RotationWarningType.AlgorithmDeprecating,
|
||||
Message = $"Key {key.KeyId} uses deprecated algorithm {key.Algorithm}. Plan migration.",
|
||||
CriticalAt = null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<KeyHistoryEntry>> GetKeyHistoryAsync(
|
||||
Guid anchorId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var entries = await _dbContext.KeyHistory
|
||||
.Where(k => k.AnchorId == anchorId)
|
||||
.OrderByDescending(k => k.AddedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return entries.Select(e => new KeyHistoryEntry
|
||||
{
|
||||
KeyId = e.KeyId,
|
||||
AddedAt = e.AddedAt,
|
||||
RevokedAt = e.RevokedAt,
|
||||
RevokeReason = e.RevokeReason,
|
||||
Algorithm = e.Algorithm,
|
||||
ExpiresAt = e.ExpiresAt
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> GetRevokedKeyIdsAsync(Guid anchorId, CancellationToken ct)
|
||||
{
|
||||
return await _dbContext.KeyHistory
|
||||
.Where(k => k.AnchorId == anchorId && k.RevokedAt != null)
|
||||
.Select(k => k.KeyId)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
private static KeyRotationResult FailedResult(
|
||||
string errorMessage,
|
||||
IReadOnlyList<string> allowedKeys,
|
||||
IReadOnlyList<string> revokedKeys) => new()
|
||||
{
|
||||
Success = false,
|
||||
AllowedKeyIds = allowedKeys,
|
||||
RevokedKeyIds = revokedKeys,
|
||||
ErrorMessage = errorMessage
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for key rotation service.
|
||||
/// </summary>
|
||||
public sealed class KeyRotationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Default actor for audit log entries when not specified.
|
||||
/// </summary>
|
||||
public string DefaultActor { get; set; } = "system";
|
||||
|
||||
/// <summary>
|
||||
/// Number of days before expiry to start warning.
|
||||
/// </summary>
|
||||
public int ExpiryWarningDays { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum key age in days before warning about rotation.
|
||||
/// </summary>
|
||||
public int MaxKeyAgeDays { get; set; } = 365;
|
||||
|
||||
/// <summary>
|
||||
/// List of deprecated algorithms to warn about.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> DeprecatedAlgorithms { get; set; } = ["RSA-2048", "SHA1-RSA"];
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using StellaOps.Signer.KeyManagement.Entities;
|
||||
|
||||
namespace StellaOps.Signer.KeyManagement;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of trust anchor manager.
|
||||
/// Implements advisory §8.3 trust anchor structure with PURL pattern matching.
|
||||
/// </summary>
|
||||
public sealed class TrustAnchorManager : ITrustAnchorManager
|
||||
{
|
||||
private readonly KeyManagementDbContext _dbContext;
|
||||
private readonly IKeyRotationService _keyRotationService;
|
||||
private readonly ILogger<TrustAnchorManager> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public TrustAnchorManager(
|
||||
KeyManagementDbContext dbContext,
|
||||
IKeyRotationService keyRotationService,
|
||||
ILogger<TrustAnchorManager> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
_keyRotationService = keyRotationService ?? throw new ArgumentNullException(nameof(keyRotationService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TrustAnchorInfo?> GetAnchorAsync(
|
||||
Guid anchorId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var entity = await _dbContext.TrustAnchors
|
||||
.FirstOrDefaultAsync(a => a.AnchorId == anchorId, ct);
|
||||
|
||||
if (entity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var keyHistory = await _keyRotationService.GetKeyHistoryAsync(anchorId, ct);
|
||||
return MapToInfo(entity, keyHistory);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TrustAnchorInfo?> FindAnchorForPurlAsync(
|
||||
string purl,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get all active anchors
|
||||
var anchors = await _dbContext.TrustAnchors
|
||||
.Where(a => a.IsActive)
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Find the most specific matching pattern
|
||||
TrustAnchorEntity? bestMatch = null;
|
||||
var bestSpecificity = -1;
|
||||
|
||||
foreach (var anchor in anchors)
|
||||
{
|
||||
if (PurlPatternMatcher.Matches(anchor.PurlPattern, purl))
|
||||
{
|
||||
var specificity = PurlPatternMatcher.GetSpecificity(anchor.PurlPattern);
|
||||
if (specificity > bestSpecificity)
|
||||
{
|
||||
bestMatch = anchor;
|
||||
bestSpecificity = specificity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatch is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var keyHistory = await _keyRotationService.GetKeyHistoryAsync(bestMatch.AnchorId, ct);
|
||||
return MapToInfo(bestMatch, keyHistory);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TrustAnchorInfo> CreateAnchorAsync(
|
||||
CreateTrustAnchorRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.PurlPattern))
|
||||
{
|
||||
throw new ArgumentException("PurlPattern is required.", nameof(request));
|
||||
}
|
||||
|
||||
// Validate PURL pattern
|
||||
if (!PurlPatternMatcher.IsValidPattern(request.PurlPattern))
|
||||
{
|
||||
throw new ArgumentException($"Invalid PURL pattern: {request.PurlPattern}", nameof(request));
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var entity = new TrustAnchorEntity
|
||||
{
|
||||
AnchorId = Guid.NewGuid(),
|
||||
PurlPattern = request.PurlPattern,
|
||||
AllowedKeyIds = request.AllowedKeyIds?.ToList() ?? [],
|
||||
AllowedPredicateTypes = request.AllowedPredicateTypes?.ToList(),
|
||||
PolicyRef = request.PolicyRef,
|
||||
PolicyVersion = request.PolicyVersion,
|
||||
RevokedKeyIds = [],
|
||||
IsActive = true,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
_dbContext.TrustAnchors.Add(entity);
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created trust anchor {AnchorId} with pattern {Pattern}",
|
||||
entity.AnchorId, entity.PurlPattern);
|
||||
|
||||
return MapToInfo(entity, []);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TrustAnchorInfo> UpdateAnchorAsync(
|
||||
Guid anchorId,
|
||||
UpdateTrustAnchorRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var entity = await _dbContext.TrustAnchors
|
||||
.FirstOrDefaultAsync(a => a.AnchorId == anchorId, ct)
|
||||
?? throw new InvalidOperationException($"Trust anchor {anchorId} not found.");
|
||||
|
||||
if (request.AllowedPredicateTypes is not null)
|
||||
{
|
||||
entity.AllowedPredicateTypes = request.AllowedPredicateTypes.ToList();
|
||||
}
|
||||
|
||||
if (request.PolicyRef is not null)
|
||||
{
|
||||
entity.PolicyRef = request.PolicyRef;
|
||||
}
|
||||
|
||||
if (request.PolicyVersion is not null)
|
||||
{
|
||||
entity.PolicyVersion = request.PolicyVersion;
|
||||
}
|
||||
|
||||
entity.UpdatedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
|
||||
_logger.LogInformation("Updated trust anchor {AnchorId}", anchorId);
|
||||
|
||||
var keyHistory = await _keyRotationService.GetKeyHistoryAsync(anchorId, ct);
|
||||
return MapToInfo(entity, keyHistory);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeactivateAnchorAsync(
|
||||
Guid anchorId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var entity = await _dbContext.TrustAnchors
|
||||
.FirstOrDefaultAsync(a => a.AnchorId == anchorId, ct)
|
||||
?? throw new InvalidOperationException($"Trust anchor {anchorId} not found.");
|
||||
|
||||
entity.IsActive = false;
|
||||
entity.UpdatedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
|
||||
_logger.LogInformation("Deactivated trust anchor {AnchorId}", anchorId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TrustVerificationResult> VerifySignatureAuthorizationAsync(
|
||||
Guid anchorId,
|
||||
string keyId,
|
||||
DateTimeOffset signedAt,
|
||||
string? predicateType = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Check key validity at signing time
|
||||
var keyValidity = await _keyRotationService.CheckKeyValidityAsync(anchorId, keyId, signedAt, ct);
|
||||
|
||||
if (!keyValidity.IsValid)
|
||||
{
|
||||
return new TrustVerificationResult
|
||||
{
|
||||
IsAuthorized = false,
|
||||
FailureReason = keyValidity.InvalidReason ?? $"Key {keyId} was not valid at {signedAt:O}.",
|
||||
KeyStatus = keyValidity.Status,
|
||||
PredicateTypeAllowed = null
|
||||
};
|
||||
}
|
||||
|
||||
// Check predicate type if specified
|
||||
bool? predicateAllowed = null;
|
||||
if (predicateType is not null)
|
||||
{
|
||||
var anchor = await GetAnchorAsync(anchorId, ct);
|
||||
if (anchor is not null && anchor.AllowedPredicateTypes is not null)
|
||||
{
|
||||
predicateAllowed = anchor.AllowedPredicateTypes.Contains(predicateType);
|
||||
if (!predicateAllowed.Value)
|
||||
{
|
||||
return new TrustVerificationResult
|
||||
{
|
||||
IsAuthorized = false,
|
||||
FailureReason = $"Predicate type '{predicateType}' is not allowed for this anchor.",
|
||||
KeyStatus = keyValidity.Status,
|
||||
PredicateTypeAllowed = false
|
||||
};
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
predicateAllowed = true; // No restriction
|
||||
}
|
||||
}
|
||||
|
||||
return new TrustVerificationResult
|
||||
{
|
||||
IsAuthorized = true,
|
||||
KeyStatus = keyValidity.Status,
|
||||
PredicateTypeAllowed = predicateAllowed
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TrustAnchorInfo>> GetActiveAnchorsAsync(
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var entities = await _dbContext.TrustAnchors
|
||||
.Where(a => a.IsActive)
|
||||
.OrderBy(a => a.PurlPattern)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var results = new List<TrustAnchorInfo>();
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
var keyHistory = await _keyRotationService.GetKeyHistoryAsync(entity.AnchorId, ct);
|
||||
results.Add(MapToInfo(entity, keyHistory));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static TrustAnchorInfo MapToInfo(TrustAnchorEntity entity, IReadOnlyList<KeyHistoryEntry> keyHistory)
|
||||
{
|
||||
return new TrustAnchorInfo
|
||||
{
|
||||
AnchorId = entity.AnchorId,
|
||||
PurlPattern = entity.PurlPattern,
|
||||
AllowedKeyIds = entity.AllowedKeyIds?.ToList() ?? [],
|
||||
AllowedPredicateTypes = entity.AllowedPredicateTypes?.ToList(),
|
||||
PolicyRef = entity.PolicyRef,
|
||||
PolicyVersion = entity.PolicyVersion,
|
||||
RevokedKeyIds = entity.RevokedKeyIds?.ToList() ?? [],
|
||||
KeyHistory = keyHistory,
|
||||
IsActive = entity.IsActive,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
UpdatedAt = entity.UpdatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PURL pattern matching utilities.
|
||||
/// Supports glob-style patterns like pkg:npm/*, pkg:maven/org.apache/*, etc.
|
||||
/// </summary>
|
||||
public static class PurlPatternMatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if a PURL pattern is valid.
|
||||
/// </summary>
|
||||
/// <param name="pattern">The pattern to validate.</param>
|
||||
/// <returns>True if valid.</returns>
|
||||
public static bool IsValidPattern(string pattern)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pattern))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must start with pkg:
|
||||
if (!pattern.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must have at least a type after pkg:
|
||||
var afterPkg = pattern.Substring(4);
|
||||
if (string.IsNullOrEmpty(afterPkg))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Valid patterns: pkg:type/*, pkg:type/namespace/*, pkg:type/namespace/name, etc.
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a PURL matches a pattern.
|
||||
/// </summary>
|
||||
/// <param name="pattern">The glob pattern (e.g., pkg:npm/*).</param>
|
||||
/// <param name="purl">The PURL to check (e.g., pkg:npm/lodash@4.17.21).</param>
|
||||
/// <returns>True if the PURL matches the pattern.</returns>
|
||||
public static bool Matches(string pattern, string purl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pattern) || string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exact match
|
||||
if (pattern.Equals(purl, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Convert glob pattern to regex
|
||||
var regexPattern = GlobToRegex(pattern);
|
||||
return Regex.IsMatch(purl, regexPattern, RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the specificity of a pattern (higher = more specific).
|
||||
/// Used to select the best matching pattern when multiple match.
|
||||
/// </summary>
|
||||
/// <param name="pattern">The pattern.</param>
|
||||
/// <returns>Specificity score.</returns>
|
||||
public static int GetSpecificity(string pattern)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pattern))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// More path segments = more specific
|
||||
var segments = pattern.Split('/').Length;
|
||||
|
||||
// Wildcards reduce specificity
|
||||
var wildcards = pattern.Count(c => c == '*');
|
||||
|
||||
// Score: segments * 10 - wildcards * 5
|
||||
return segments * 10 - wildcards * 5;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a glob pattern to a regex pattern.
|
||||
/// </summary>
|
||||
private static string GlobToRegex(string glob)
|
||||
{
|
||||
// Escape regex special characters except * and ?
|
||||
var escaped = Regex.Escape(glob)
|
||||
.Replace("\\*", ".*") // * matches any characters
|
||||
.Replace("\\?", "."); // ? matches single character
|
||||
|
||||
return $"^{escaped}$";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user