Files
git.stella-ops.org/src/Cryptography/StellaOps.Cryptography.Plugin.Eidas/Timestamping/EuTrustListService.cs
2026-02-01 21:37:40 +02:00

407 lines
14 KiB
C#

// -----------------------------------------------------------------------------
// 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;
/// <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)
});
}
}
}
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<X509Certificate2>? 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<X509Certificate2>();
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;
}
}