sprints work.
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// EuTrustListService.cs
|
||||
// Sprint: SPRINT_20260119_011 eIDAS Qualified Timestamp Support
|
||||
// Task: QTS-004 - EU Trust List Integration
|
||||
// Description: Implementation of EU Trusted List service.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Xml.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Cryptography.Plugin.Eidas.Timestamping;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of <see cref="IEuTrustListService"/>.
|
||||
/// </summary>
|
||||
public sealed class EuTrustListService : IEuTrustListService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly QualifiedTimestampingConfiguration _config;
|
||||
private readonly ILogger<EuTrustListService> _logger;
|
||||
|
||||
private List<TrustListEntry>? _cachedEntries;
|
||||
private DateTimeOffset? _lastUpdate;
|
||||
private readonly SemaphoreSlim _refreshLock = new(1, 1);
|
||||
|
||||
// ETSI TS 119 612 namespaces
|
||||
private static readonly XNamespace TslNs = "http://uri.etsi.org/02231/v2#";
|
||||
private static readonly XNamespace TslAdditionalNs = "http://uri.etsi.org/02231/v2/additionaltypes#";
|
||||
|
||||
// Service type identifiers
|
||||
private const string QtsaServiceType = "http://uri.etsi.org/TrstSvc/Svctype/TSA/QTST";
|
||||
private const string TsaServiceType = "http://uri.etsi.org/TrstSvc/Svctype/TSA";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EuTrustListService"/> class.
|
||||
/// </summary>
|
||||
public EuTrustListService(
|
||||
HttpClient httpClient,
|
||||
IOptions<QualifiedTimestampingConfiguration> config,
|
||||
ILogger<EuTrustListService> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_config = config.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TrustListEntry?> GetTsaQualificationAsync(
|
||||
string tsaIdentifier,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureCacheLoadedAsync(cancellationToken);
|
||||
|
||||
return _cachedEntries?.FirstOrDefault(e =>
|
||||
e.ServiceSupplyPoints?.Any(s =>
|
||||
s.Contains(tsaIdentifier, StringComparison.OrdinalIgnoreCase)) == true ||
|
||||
e.ServiceName.Contains(tsaIdentifier, StringComparison.OrdinalIgnoreCase) ||
|
||||
e.TspName.Contains(tsaIdentifier, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> IsQualifiedTsaAsync(
|
||||
X509Certificate2 tsaCertificate,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureCacheLoadedAsync(cancellationToken);
|
||||
|
||||
if (_cachedEntries is null) return false;
|
||||
|
||||
// Match by certificate thumbprint or subject
|
||||
foreach (var entry in _cachedEntries.Where(e => e.IsQualifiedTimestampService))
|
||||
{
|
||||
if (entry.ServiceCertificates is null) continue;
|
||||
|
||||
foreach (var cert in entry.ServiceCertificates)
|
||||
{
|
||||
if (cert.Thumbprint == tsaCertificate.Thumbprint)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> WasQualifiedTsaAtTimeAsync(
|
||||
X509Certificate2 tsaCertificate,
|
||||
DateTimeOffset atTime,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureCacheLoadedAsync(cancellationToken);
|
||||
|
||||
if (_cachedEntries is null) return false;
|
||||
|
||||
foreach (var entry in _cachedEntries)
|
||||
{
|
||||
var matchesCert = entry.ServiceCertificates?.Any(c => c.Thumbprint == tsaCertificate.Thumbprint) == true;
|
||||
if (!matchesCert) continue;
|
||||
|
||||
// Check status at the given time
|
||||
var statusAtTime = GetStatusAtTime(entry, atTime);
|
||||
if (statusAtTime == ServiceStatus.Granted || statusAtTime == ServiceStatus.Accredited)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RefreshTrustListAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _refreshLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Refreshing EU Trust List from {Url}", _config.TrustList.LotlUrl);
|
||||
|
||||
// First, check for offline path
|
||||
if (!string.IsNullOrEmpty(_config.TrustList.OfflinePath) &&
|
||||
File.Exists(_config.TrustList.OfflinePath))
|
||||
{
|
||||
var offlineContent = await File.ReadAllTextAsync(_config.TrustList.OfflinePath, cancellationToken);
|
||||
await ParseTrustListAsync(offlineContent, cancellationToken);
|
||||
_logger.LogInformation("Loaded trust list from offline path");
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch LOTL
|
||||
var response = await _httpClient.GetAsync(_config.TrustList.LotlUrl, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
// Verify signature if required
|
||||
if (_config.TrustList.VerifySignature)
|
||||
{
|
||||
VerifyTrustListSignature(content);
|
||||
}
|
||||
|
||||
await ParseTrustListAsync(content, cancellationToken);
|
||||
|
||||
_lastUpdate = DateTimeOffset.UtcNow;
|
||||
_logger.LogInformation("Refreshed EU Trust List with {Count} TSA entries",
|
||||
_cachedEntries?.Count(e => e.IsQualifiedTimestampService) ?? 0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DateTimeOffset?> GetLastUpdateTimeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_lastUpdate);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TrustListEntry>> GetQualifiedTsaProvidersAsync(
|
||||
string? countryCode = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureCacheLoadedAsync(cancellationToken);
|
||||
|
||||
if (_cachedEntries is null) return [];
|
||||
|
||||
var query = _cachedEntries.Where(e => e.IsQualifiedTimestampService);
|
||||
|
||||
if (!string.IsNullOrEmpty(countryCode))
|
||||
{
|
||||
query = query.Where(e => e.CountryCode.Equals(countryCode, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return query.ToList();
|
||||
}
|
||||
|
||||
private async Task EnsureCacheLoadedAsync(CancellationToken ct)
|
||||
{
|
||||
if (_cachedEntries is not null)
|
||||
{
|
||||
// Check if cache is still valid
|
||||
if (_lastUpdate.HasValue &&
|
||||
DateTimeOffset.UtcNow - _lastUpdate.Value < _config.TrustList.CacheTtl)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await RefreshTrustListAsync(ct);
|
||||
}
|
||||
|
||||
private Task ParseTrustListAsync(string xmlContent, CancellationToken ct)
|
||||
{
|
||||
var doc = XDocument.Parse(xmlContent);
|
||||
var entries = new List<TrustListEntry>();
|
||||
|
||||
// Parse LOTL to get individual country trust lists
|
||||
var lotlPointers = doc.Descendants(TslNs + "OtherTSLPointer");
|
||||
|
||||
foreach (var pointer in lotlPointers)
|
||||
{
|
||||
var tslLocation = pointer.Element(TslNs + "TSLLocation")?.Value;
|
||||
if (string.IsNullOrEmpty(tslLocation)) continue;
|
||||
|
||||
// For now, parse the LOTL directly
|
||||
// In production, would fetch and parse each country's TL
|
||||
}
|
||||
|
||||
// Parse trust service providers directly from the document
|
||||
var tspList = doc.Descendants(TslNs + "TrustServiceProvider");
|
||||
|
||||
foreach (var tsp in tspList)
|
||||
{
|
||||
var tspName = tsp.Descendants(TslNs + "Name").FirstOrDefault()?.Value ?? "Unknown";
|
||||
|
||||
var services = tsp.Descendants(TslNs + "TSPService");
|
||||
foreach (var service in services)
|
||||
{
|
||||
var serviceInfo = service.Element(TslNs + "ServiceInformation");
|
||||
if (serviceInfo is null) continue;
|
||||
|
||||
var serviceType = serviceInfo.Element(TslNs + "ServiceTypeIdentifier")?.Value;
|
||||
var serviceName = serviceInfo.Descendants(TslNs + "Name").FirstOrDefault()?.Value ?? "Unknown";
|
||||
var statusUri = serviceInfo.Element(TslNs + "ServiceStatus")?.Value;
|
||||
var statusStarting = serviceInfo.Element(TslNs + "StatusStartingTime")?.Value;
|
||||
|
||||
// Only include TSA services
|
||||
if (serviceType?.Contains("TSA", StringComparison.OrdinalIgnoreCase) != true)
|
||||
continue;
|
||||
|
||||
var supplyPoints = serviceInfo.Descendants(TslNs + "ServiceSupplyPoint")
|
||||
.Select(s => s.Value)
|
||||
.ToList();
|
||||
|
||||
// Parse status history
|
||||
var historyList = new List<ServiceStatusHistory>();
|
||||
var history = service.Element(TslNs + "ServiceHistory");
|
||||
if (history is not null)
|
||||
{
|
||||
foreach (var histEntry in history.Elements(TslNs + "ServiceHistoryInstance"))
|
||||
{
|
||||
var hStatus = histEntry.Element(TslNs + "ServiceStatus")?.Value;
|
||||
var hTime = histEntry.Element(TslNs + "StatusStartingTime")?.Value;
|
||||
|
||||
if (hTime is not null)
|
||||
{
|
||||
historyList.Add(new ServiceStatusHistory
|
||||
{
|
||||
Status = ParseStatus(hStatus),
|
||||
StatusStarting = DateTimeOffset.Parse(hTime)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entries.Add(new TrustListEntry
|
||||
{
|
||||
TspName = tspName,
|
||||
ServiceName = serviceName,
|
||||
Status = ParseStatus(statusUri),
|
||||
StatusStarting = statusStarting is not null
|
||||
? DateTimeOffset.Parse(statusStarting)
|
||||
: DateTimeOffset.MinValue,
|
||||
ServiceTypeIdentifier = serviceType ?? "",
|
||||
CountryCode = ExtractCountryCode(tspName),
|
||||
ServiceSupplyPoints = supplyPoints,
|
||||
StatusHistory = historyList
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_cachedEntries = entries;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static ServiceStatus ParseStatus(string? statusUri)
|
||||
{
|
||||
if (string.IsNullOrEmpty(statusUri)) return ServiceStatus.Unknown;
|
||||
|
||||
return statusUri switch
|
||||
{
|
||||
_ when statusUri.Contains("granted", StringComparison.OrdinalIgnoreCase) => ServiceStatus.Granted,
|
||||
_ when statusUri.Contains("withdrawn", StringComparison.OrdinalIgnoreCase) => ServiceStatus.Withdrawn,
|
||||
_ when statusUri.Contains("deprecated", StringComparison.OrdinalIgnoreCase) => ServiceStatus.Deprecated,
|
||||
_ when statusUri.Contains("supervision", StringComparison.OrdinalIgnoreCase) => ServiceStatus.UnderSupervision,
|
||||
_ when statusUri.Contains("accredited", StringComparison.OrdinalIgnoreCase) => ServiceStatus.Accredited,
|
||||
_ => ServiceStatus.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
private static string ExtractCountryCode(string tspName)
|
||||
{
|
||||
// Simple heuristic - would need proper parsing in production
|
||||
var parts = tspName.Split([' ', '-', '_'], StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (part.Length == 2 && part.All(char.IsLetter))
|
||||
{
|
||||
return part.ToUpperInvariant();
|
||||
}
|
||||
}
|
||||
return "EU";
|
||||
}
|
||||
|
||||
private static ServiceStatus GetStatusAtTime(TrustListEntry entry, DateTimeOffset atTime)
|
||||
{
|
||||
// Check history in reverse chronological order
|
||||
if (entry.StatusHistory is { Count: > 0 })
|
||||
{
|
||||
var sortedHistory = entry.StatusHistory
|
||||
.OrderByDescending(h => h.StatusStarting)
|
||||
.ToList();
|
||||
|
||||
foreach (var hist in sortedHistory)
|
||||
{
|
||||
if (atTime >= hist.StatusStarting)
|
||||
{
|
||||
return hist.Status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to current status if time is after status starting
|
||||
if (atTime >= entry.StatusStarting)
|
||||
{
|
||||
return entry.Status;
|
||||
}
|
||||
|
||||
return ServiceStatus.Unknown;
|
||||
}
|
||||
|
||||
private void VerifyTrustListSignature(string xmlContent)
|
||||
{
|
||||
// Would verify the XML signature on the trust list
|
||||
// Using XmlDsig signature verification
|
||||
_logger.LogDebug("Verifying trust list signature");
|
||||
// Implementation would use System.Security.Cryptography.Xml
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user