feat(rate-limiting): Implement core rate limiting functionality with configuration, decision-making, metrics, middleware, and service registration

- Add RateLimitConfig for configuration management with YAML binding support.
- Introduce RateLimitDecision to encapsulate the result of rate limit checks.
- Implement RateLimitMetrics for OpenTelemetry metrics tracking.
- Create RateLimitMiddleware for enforcing rate limits on incoming requests.
- Develop RateLimitService to orchestrate instance and environment rate limit checks.
- Add RateLimitServiceCollectionExtensions for dependency injection registration.
This commit is contained in:
master
2025-12-17 18:02:37 +02:00
parent 394b57f6bf
commit 8bbfe4d2d2
211 changed files with 47179 additions and 1590 deletions

View File

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

View File

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

View File

@@ -0,0 +1,418 @@
// -----------------------------------------------------------------------------
// TemporalKeyVerificationTests.cs
// Sprint: SPRINT_0501_0008_0001_proof_chain_key_rotation
// Task: PROOF-KEY-0014 - Temporal verification tests (key valid at time T)
// Description: Tests verifying key validity at specific points in time
// -----------------------------------------------------------------------------
using System;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Signer.KeyManagement;
using StellaOps.Signer.KeyManagement.Entities;
using Xunit;
namespace StellaOps.Signer.Tests.KeyManagement;
/// <summary>
/// Temporal key verification tests.
/// Validates that keys are correctly checked for validity at specific points in time.
/// This is critical for verifying historical proofs that were signed before key rotation.
/// </summary>
public class TemporalKeyVerificationTests : IDisposable
{
private readonly KeyManagementDbContext _dbContext;
private readonly KeyRotationService _service;
private readonly FakeTimeProvider _timeProvider;
// Timeline:
// 2024-01-15: key-2024 added
// 2024-06-15: key-2025 added (overlap period begins)
// 2025-01-15: key-2024 revoked (overlap period ends)
// 2025-06-15: current time
private readonly DateTimeOffset _key2024AddedAt = new(2024, 1, 15, 0, 0, 0, TimeSpan.Zero);
private readonly DateTimeOffset _key2025AddedAt = new(2024, 6, 15, 0, 0, 0, TimeSpan.Zero);
private readonly DateTimeOffset _key2024RevokedAt = new(2025, 1, 15, 0, 0, 0, TimeSpan.Zero);
private readonly DateTimeOffset _currentTime = new(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
public TemporalKeyVerificationTests()
{
var options = new DbContextOptionsBuilder<KeyManagementDbContext>()
.UseInMemoryDatabase(databaseName: $"TemporalTestDb_{Guid.NewGuid()}")
.Options;
_dbContext = new KeyManagementDbContext(options);
_timeProvider = new FakeTimeProvider(_currentTime);
_service = new KeyRotationService(
_dbContext,
NullLogger<KeyRotationService>.Instance,
Options.Create(new KeyRotationOptions
{
DefaultActor = "test-user",
ExpiryWarningDays = 60,
MaxKeyAgeDays = 365,
DeprecatedAlgorithms = ["RSA-2048", "SHA1-RSA"]
}),
_timeProvider);
}
public void Dispose()
{
_dbContext.Dispose();
GC.SuppressFinalize(this);
}
#region Key Lifecycle Timeline Tests
[Fact]
public async Task CheckKeyValidity_KeyNotYetAdded_ReturnsNotYetValid()
{
// Arrange
var anchor = await CreateTestAnchorWithTimelineAsync();
var beforeKeyAdded = _key2024AddedAt.AddDays(-30); // Dec 2023
// Act
var result = await _service.CheckKeyValidityAsync(anchor.Id, "key-2024", beforeKeyAdded);
// Assert
result.IsValid.Should().BeFalse();
result.Status.Should().Be(KeyStatus.NotYetValid);
result.InvalidReason.Should().Contain("not yet added");
}
[Fact]
public async Task CheckKeyValidity_KeyActiveNoRevocation_ReturnsValid()
{
// Arrange
var anchor = await CreateTestAnchorWithTimelineAsync();
var duringActiveWindow = _key2024AddedAt.AddMonths(3); // April 2024
// Act
var result = await _service.CheckKeyValidityAsync(anchor.Id, "key-2024", duringActiveWindow);
// Assert
result.IsValid.Should().BeTrue();
result.Status.Should().Be(KeyStatus.Active);
result.AddedAt.Should().Be(_key2024AddedAt);
}
[Fact]
public async Task CheckKeyValidity_KeyRevokedButSignedBefore_ReturnsValid()
{
// Arrange - proof was signed during overlap period before key-2024 was revoked
var anchor = await CreateTestAnchorWithTimelineAsync();
var signedDuringOverlap = _key2024RevokedAt.AddDays(-30); // Dec 2024
// Act
var result = await _service.CheckKeyValidityAsync(anchor.Id, "key-2024", signedDuringOverlap);
// Assert - key-2024 should be valid because signature was made before revocation
result.IsValid.Should().BeTrue();
result.Status.Should().Be(KeyStatus.Active);
}
[Fact]
public async Task CheckKeyValidity_KeyRevokedAndSignedAfter_ReturnsRevoked()
{
// Arrange - proof was signed after key-2024 was revoked
var anchor = await CreateTestAnchorWithTimelineAsync();
var signedAfterRevocation = _key2024RevokedAt.AddDays(30); // Feb 2025
// Act
var result = await _service.CheckKeyValidityAsync(anchor.Id, "key-2024", signedAfterRevocation);
// Assert - key-2024 should be invalid because signature was made after revocation
result.IsValid.Should().BeFalse();
result.Status.Should().Be(KeyStatus.Revoked);
result.RevokedAt.Should().Be(_key2024RevokedAt);
}
[Fact]
public async Task CheckKeyValidity_NewKeyAfterOldRevoked_ReturnsValid()
{
// Arrange - proof was signed with key-2025 after key-2024 was revoked
var anchor = await CreateTestAnchorWithTimelineAsync();
var signedWithNewKey = _key2024RevokedAt.AddDays(30); // Feb 2025
// Act
var result = await _service.CheckKeyValidityAsync(anchor.Id, "key-2025", signedWithNewKey);
// Assert - key-2025 should be valid
result.IsValid.Should().BeTrue();
result.Status.Should().Be(KeyStatus.Active);
result.AddedAt.Should().Be(_key2025AddedAt);
}
#endregion
#region Overlap Period Tests
[Fact]
public async Task CheckKeyValidity_BothKeysValidDuringOverlap_BothReturnValid()
{
// Arrange - during overlap period (Jun 2024 - Jan 2025), both keys should be valid
var anchor = await CreateTestAnchorWithTimelineAsync();
var duringOverlap = new DateTimeOffset(2024, 9, 15, 0, 0, 0, TimeSpan.Zero); // Sep 2024
// Act
var result2024 = await _service.CheckKeyValidityAsync(anchor.Id, "key-2024", duringOverlap);
var result2025 = await _service.CheckKeyValidityAsync(anchor.Id, "key-2025", duringOverlap);
// Assert - both keys should be valid during overlap
result2024.IsValid.Should().BeTrue();
result2024.Status.Should().Be(KeyStatus.Active);
result2025.IsValid.Should().BeTrue();
result2025.Status.Should().Be(KeyStatus.Active);
}
[Fact]
public async Task CheckKeyValidity_ExactlyAtRevocationTime_ReturnsRevoked()
{
// Arrange - checking exactly at the moment of revocation
var anchor = await CreateTestAnchorWithTimelineAsync();
// Act - at exact revocation time, key is already revoked
var result = await _service.CheckKeyValidityAsync(anchor.Id, "key-2024", _key2024RevokedAt);
// Assert - at revocation time, key should be considered revoked
result.IsValid.Should().BeFalse();
result.Status.Should().Be(KeyStatus.Revoked);
}
[Fact]
public async Task CheckKeyValidity_OneMillisecondBeforeRevocation_ReturnsValid()
{
// Arrange - one millisecond before revocation
var anchor = await CreateTestAnchorWithTimelineAsync();
var justBeforeRevocation = _key2024RevokedAt.AddMilliseconds(-1);
// Act
var result = await _service.CheckKeyValidityAsync(anchor.Id, "key-2024", justBeforeRevocation);
// Assert - key should still be valid
result.IsValid.Should().BeTrue();
result.Status.Should().Be(KeyStatus.Active);
}
#endregion
#region Key Expiry Tests
[Fact]
public async Task CheckKeyValidity_KeyExpiredButSignedBefore_ReturnsValid()
{
// Arrange - key with expiry date
var anchor = await CreateTestAnchorWithExpiringKeyAsync();
var expiryDate = new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero);
var signedBeforeExpiry = expiryDate.AddDays(-30); // Feb 2025
// Act
var result = await _service.CheckKeyValidityAsync(anchor.Id, "expiring-key", signedBeforeExpiry);
// Assert - should be valid because signed before expiry
result.IsValid.Should().BeTrue();
result.Status.Should().Be(KeyStatus.Active);
}
[Fact]
public async Task CheckKeyValidity_KeyExpiredAndSignedAfter_ReturnsExpired()
{
// Arrange - key with expiry date
var anchor = await CreateTestAnchorWithExpiringKeyAsync();
var expiryDate = new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero);
var signedAfterExpiry = expiryDate.AddDays(30); // April 2025
// Act
var result = await _service.CheckKeyValidityAsync(anchor.Id, "expiring-key", signedAfterExpiry);
// Assert - should be invalid because signed after expiry
result.IsValid.Should().BeFalse();
result.Status.Should().Be(KeyStatus.Expired);
}
#endregion
#region Unknown Key Tests
[Fact]
public async Task CheckKeyValidity_UnknownKey_ReturnsUnknown()
{
// Arrange
var anchor = await CreateTestAnchorWithTimelineAsync();
// Act
var result = await _service.CheckKeyValidityAsync(anchor.Id, "nonexistent-key", _currentTime);
// Assert
result.IsValid.Should().BeFalse();
result.Status.Should().Be(KeyStatus.Unknown);
result.InvalidReason.Should().Contain("not found");
}
[Fact]
public async Task CheckKeyValidity_UnknownAnchor_ThrowsKeyNotFoundException()
{
// Arrange
var unknownAnchorId = Guid.NewGuid();
// Act & Assert
await Assert.ThrowsAsync<KeyNotFoundException>(
() => _service.CheckKeyValidityAsync(unknownAnchorId, "any-key", _currentTime));
}
#endregion
#region Determinism Tests
[Fact]
public async Task CheckKeyValidity_SameInputs_ReturnsSameResult()
{
// Arrange - determinism is critical for audit verification
var anchor = await CreateTestAnchorWithTimelineAsync();
var checkTime = new DateTimeOffset(2024, 9, 15, 10, 30, 45, TimeSpan.Zero);
// Act - call multiple times
var result1 = await _service.CheckKeyValidityAsync(anchor.Id, "key-2024", checkTime);
var result2 = await _service.CheckKeyValidityAsync(anchor.Id, "key-2024", checkTime);
var result3 = await _service.CheckKeyValidityAsync(anchor.Id, "key-2024", checkTime);
// Assert - all results should be identical
result1.Should().BeEquivalentTo(result2);
result2.Should().BeEquivalentTo(result3);
}
[Fact]
public async Task CheckKeyValidity_DifferentTimezones_SameUtcTime_ReturnsSameResult()
{
// Arrange - different timezone representations of same moment
var anchor = await CreateTestAnchorWithTimelineAsync();
var utcTime = new DateTimeOffset(2024, 9, 15, 12, 0, 0, TimeSpan.Zero);
var pstTime = new DateTimeOffset(2024, 9, 15, 4, 0, 0, TimeSpan.FromHours(-8));
var jstTime = new DateTimeOffset(2024, 9, 15, 21, 0, 0, TimeSpan.FromHours(9));
// Act
var resultUtc = await _service.CheckKeyValidityAsync(anchor.Id, "key-2024", utcTime);
var resultPst = await _service.CheckKeyValidityAsync(anchor.Id, "key-2024", pstTime);
var resultJst = await _service.CheckKeyValidityAsync(anchor.Id, "key-2024", jstTime);
// Assert - all should return same result (same UTC instant)
resultUtc.IsValid.Should().Be(resultPst.IsValid);
resultPst.IsValid.Should().Be(resultJst.IsValid);
resultUtc.Status.Should().Be(resultPst.Status);
resultPst.Status.Should().Be(resultJst.Status);
}
#endregion
#region Helper Methods
private async Task<TrustAnchorEntity> CreateTestAnchorWithTimelineAsync()
{
var anchor = new TrustAnchorEntity
{
Id = Guid.NewGuid(),
PurlPattern = "pkg:npm/*",
AllowedKeyIds = ["key-2024", "key-2025"],
RevokedKeyIds = ["key-2024"],
PolicyVersion = "v1.0.0",
CreatedAt = _key2024AddedAt,
UpdatedAt = _key2024RevokedAt
};
var keyHistory = new[]
{
new KeyHistoryEntity
{
Id = Guid.NewGuid(),
TrustAnchorId = anchor.Id,
KeyId = "key-2024",
Algorithm = "Ed25519",
AddedAt = _key2024AddedAt,
RevokedAt = _key2024RevokedAt,
RevokeReason = "annual-rotation",
CreatedBy = "test-user"
},
new KeyHistoryEntity
{
Id = Guid.NewGuid(),
TrustAnchorId = anchor.Id,
KeyId = "key-2025",
Algorithm = "Ed25519",
AddedAt = _key2025AddedAt,
RevokedAt = null,
RevokeReason = null,
CreatedBy = "test-user"
}
};
_dbContext.TrustAnchors.Add(anchor);
_dbContext.KeyHistories.AddRange(keyHistory);
await _dbContext.SaveChangesAsync();
return anchor;
}
private async Task<TrustAnchorEntity> CreateTestAnchorWithExpiringKeyAsync()
{
var anchor = new TrustAnchorEntity
{
Id = Guid.NewGuid(),
PurlPattern = "pkg:pypi/*",
AllowedKeyIds = ["expiring-key"],
RevokedKeyIds = [],
PolicyVersion = "v1.0.0",
CreatedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
UpdatedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero)
};
var keyHistory = new KeyHistoryEntity
{
Id = Guid.NewGuid(),
TrustAnchorId = anchor.Id,
KeyId = "expiring-key",
Algorithm = "Ed25519",
AddedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
ExpiresAt = new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero),
RevokedAt = null,
RevokeReason = null,
CreatedBy = "test-user"
};
_dbContext.TrustAnchors.Add(anchor);
_dbContext.KeyHistories.Add(keyHistory);
await _dbContext.SaveChangesAsync();
return anchor;
}
#endregion
}
/// <summary>
/// Fake time provider for testing temporal logic.
/// </summary>
public class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _currentTime;
public FakeTimeProvider(DateTimeOffset startTime)
{
_currentTime = startTime;
}
public override DateTimeOffset GetUtcNow() => _currentTime;
public void SetTime(DateTimeOffset newTime) => _currentTime = newTime;
public void AdvanceBy(TimeSpan duration) => _currentTime += duration;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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