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

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