// ----------------------------------------------------------------------------- // 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 Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.Xml; using System.Xml; using System.Xml.Linq; namespace StellaOps.Cryptography.Plugin.Eidas.Timestamping; /// /// Implementation of . /// public sealed class EuTrustListService : IEuTrustListService { private readonly HttpClient _httpClient; private readonly QualifiedTimestampingConfiguration _config; private readonly ILogger _logger; private List? _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"; /// /// Initializes a new instance of the class. /// public EuTrustListService( HttpClient httpClient, IOptions config, ILogger logger) { _httpClient = httpClient; _config = config.Value; _logger = logger; } /// public async Task 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)); } /// public async Task 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; } /// public async Task 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; } /// 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(); } } /// public Task GetLastUpdateTimeAsync(CancellationToken cancellationToken = default) { return Task.FromResult(_lastUpdate); } /// public async Task> 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(); // 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(); 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) }); } } } var certificates = ParseServiceCertificates(serviceInfo); 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, ServiceCertificates = certificates }); } } _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) { _logger.LogDebug("Verifying trust list signature"); var xmlDoc = new XmlDocument { PreserveWhitespace = true, XmlResolver = null }; xmlDoc.LoadXml(xmlContent); var nsManager = new XmlNamespaceManager(xmlDoc.NameTable); nsManager.AddNamespace("ds", SignedXml.XmlDsigNamespaceUrl); var signatureNode = xmlDoc.SelectSingleNode("//ds:Signature", nsManager) as XmlElement; if (signatureNode is null) { throw new CryptographicException("Trust list signature element not found."); } var signedXml = new SignedXml(xmlDoc); signedXml.LoadXml(signatureNode); if (!signedXml.CheckSignature()) { throw new CryptographicException("Trust list signature validation failed."); } } private static IReadOnlyList? ParseServiceCertificates(XElement serviceInfo) { var certElements = serviceInfo.Descendants() .Where(e => e.Name.LocalName.Equals("X509Certificate", StringComparison.OrdinalIgnoreCase)) .Select(e => e.Value) .Where(v => !string.IsNullOrWhiteSpace(v)) .ToList(); if (certElements.Count == 0) { return null; } var certificates = new List(); foreach (var certBase64 in certElements) { try { var raw = Convert.FromBase64String(certBase64.Trim()); certificates.Add(X509CertificateLoader.LoadCertificate(raw)); } catch (FormatException) { // Ignore malformed certificate entries. } catch (CryptographicException) { // Ignore malformed certificate entries. } } return certificates.Count > 0 ? certificates : null; } }