407 lines
14 KiB
C#
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;
|
|
}
|
|
}
|