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