// ----------------------------------------------------------------------------- // QualifiedTimestampVerifier.cs // Sprint: SPRINT_20260119_011 eIDAS Qualified Timestamp Support // Task: QTS-006 - Verification for Qualified Timestamps // Description: Implementation of qualified timestamp verification. // ----------------------------------------------------------------------------- using System.Formats.Asn1; using System.Security.Cryptography; using System.Security.Cryptography.Pkcs; using System.Security.Cryptography.X509Certificates; using Microsoft.Extensions.Logging; namespace StellaOps.Cryptography.Plugin.Eidas.Timestamping; /// /// Implementation of . /// public sealed class QualifiedTimestampVerifier : IQualifiedTimestampVerifier { private readonly IEuTrustListService _trustListService; private readonly ILogger _logger; private const string SignatureTimestampOid = "1.2.840.113549.1.9.16.2.14"; private const string TstInfoOid = "1.2.840.113549.1.9.16.1.4"; /// /// Initializes a new instance of the class. /// public QualifiedTimestampVerifier( IEuTrustListService trustListService, ILogger logger) { _trustListService = trustListService; _logger = logger; } /// public async Task VerifyAsync( ReadOnlyMemory timestampToken, ReadOnlyMemory originalData, QualifiedTimestampVerificationOptions? options = null, CancellationToken cancellationToken = default) { options ??= new QualifiedTimestampVerificationOptions(); var errors = new List(); var warnings = new List(); try { // Decode the timestamp token (RFC 3161 TimeStampResp) var signedCms = new SignedCms(); signedCms.Decode(timestampToken.ToArray()); // Verify CMS signature try { signedCms.CheckSignature(verifySignatureOnly: false); } catch (CryptographicException ex) { return QualifiedTimestampVerificationResult.Failed($"Signature verification failed: {ex.Message}"); } // Get TSA certificate var tsaCert = signedCms.SignerInfos[0].Certificate; if (tsaCert is null) { return QualifiedTimestampVerificationResult.Failed("TSA certificate not found in timestamp"); } // Parse TSTInfo var tstInfo = ParseTstInfo(signedCms.ContentInfo.Content); if (tstInfo is null) { return QualifiedTimestampVerificationResult.Failed("Failed to parse TSTInfo"); } // Verify message imprint var expectedHash = ComputeHash(originalData.ToArray(), tstInfo.DigestAlgorithm); if (!tstInfo.MessageImprint.SequenceEqual(expectedHash)) { return QualifiedTimestampVerificationResult.Failed("Message imprint mismatch"); } // Check TSA qualification var isQualified = await _trustListService.IsQualifiedTsaAsync(tsaCert, cancellationToken); var wasQualifiedAtTime = isQualified; if (options.CheckHistoricalQualification && tstInfo.GenerationTime.HasValue) { wasQualifiedAtTime = await _trustListService.WasQualifiedTsaAtTimeAsync( tsaCert, tstInfo.GenerationTime.Value, cancellationToken); } if (options.RequireQualifiedTsa && !isQualified) { errors.Add("TSA is not on the EU Trusted List as a qualified TSA"); } if (options.RequireQualifiedTsa && !wasQualifiedAtTime) { errors.Add("TSA was not qualified at the time of timestamping"); } // Get trust list entry for additional info TrustListEntry? trustListEntry = null; if (isQualified) { trustListEntry = await _trustListService.GetTsaQualificationAsync( tsaCert.Subject, cancellationToken); } _logger.LogDebug( "Timestamp verification: valid={Valid}, qualified={Qualified}, time={Time}", errors.Count == 0, isQualified, tstInfo.GenerationTime); return new QualifiedTimestampVerificationResult { IsValid = errors.Count == 0, IsQualifiedTsa = isQualified, WasQualifiedAtTime = wasQualifiedAtTime, GenerationTime = tstInfo.GenerationTime, TsaCertificate = tsaCert, TsaName = tsaCert.Subject, DigestAlgorithm = tstInfo.DigestAlgorithm, Errors = errors.Count > 0 ? errors : null, Warnings = warnings.Count > 0 ? warnings : null, TrustListEntry = trustListEntry }; } catch (Exception ex) { _logger.LogError(ex, "Timestamp verification failed"); return QualifiedTimestampVerificationResult.Failed(ex.Message); } } /// public async Task VerifyCadesAsync( ReadOnlyMemory signature, ReadOnlyMemory originalData, QualifiedTimestampVerificationOptions? options = null, CancellationToken cancellationToken = default) { options ??= new QualifiedTimestampVerificationOptions(); var errors = new List(); var warnings = new List(); try { // Decode CAdES signature var signedCms = new SignedCms(new ContentInfo(originalData.ToArray()), detached: true); try { signedCms.Decode(signature.ToArray()); signedCms.CheckSignature(verifySignatureOnly: false); } catch (CryptographicException ex) { return CadesVerificationResult.Failed($"Signature verification failed: {ex.Message}"); } var signerInfo = signedCms.SignerInfos[0]; var signerCert = signerInfo.Certificate; // Detect CAdES level var level = DetectCadesLevel(signedCms); ValidateCadesFormat(signedCms, level, errors); // Get signing time DateTimeOffset? signingTime = null; var signingTimeAttr = signerInfo.SignedAttributes .Cast() .FirstOrDefault(a => a.Oid?.Value == "1.2.840.113549.1.9.5"); if (signingTimeAttr is not null) { var pkcs9Time = new Pkcs9SigningTime(signingTimeAttr.Values[0].RawData); signingTime = pkcs9Time.SigningTime; } // Verify timestamp if present QualifiedTimestampVerificationResult? timestampResult = null; var isTimestampValid = false; if (level >= CadesLevel.CadesT) { var tsAttr = signerInfo.UnsignedAttributes .Cast() .FirstOrDefault(a => a.Oid?.Value == SignatureTimestampOid); if (tsAttr is not null) { var signatureValue = signerInfo.GetSignature(); timestampResult = await VerifyAsync( tsAttr.Values[0].RawData, signatureValue, options, cancellationToken); isTimestampValid = timestampResult.IsValid; if (!isTimestampValid) { errors.Add("Timestamp verification failed"); } } else { errors.Add("Expected timestamp attribute not found"); } } // Check LTV completeness if required var isLtvComplete = false; var shouldCheckLtv = options.VerifyLtvCompleteness || level >= CadesLevel.CadesLT; if (shouldCheckLtv) { isLtvComplete = VerifyLtvCompleteness(signedCms); if (!isLtvComplete) { errors.Add("LTV data is incomplete"); } } return new CadesVerificationResult { IsSignatureValid = errors.Count == 0, IsTimestampValid = isTimestampValid, DetectedLevel = level, TimestampResult = timestampResult, SignerCertificate = signerCert, SigningTime = signingTime ?? timestampResult?.GenerationTime, IsLtvComplete = isLtvComplete, Errors = errors.Count > 0 ? errors : null, Warnings = warnings.Count > 0 ? warnings : null }; } catch (Exception ex) { _logger.LogError(ex, "CAdES verification failed"); return CadesVerificationResult.Failed(ex.Message); } } private static TstInfoData? ParseTstInfo(byte[] content) { try { var reader = new AsnReader(content, AsnEncodingRules.DER); var sequence = reader.ReadSequence(); // Version _ = sequence.ReadInteger(); // Policy _ = sequence.ReadObjectIdentifier(); // MessageImprint var imprintSeq = sequence.ReadSequence(); var algorithmSeq = imprintSeq.ReadSequence(); var algorithmOid = algorithmSeq.ReadObjectIdentifier(); var messageImprint = imprintSeq.ReadOctetString(); // SerialNumber _ = sequence.ReadInteger(); // GenTime var genTime = sequence.ReadGeneralizedTime(); return new TstInfoData { DigestAlgorithm = MapOidToAlgorithm(algorithmOid), MessageImprint = messageImprint, GenerationTime = genTime }; } catch { return null; } } private static string MapOidToAlgorithm(string oid) => oid switch { "2.16.840.1.101.3.4.2.1" => "SHA256", "2.16.840.1.101.3.4.2.2" => "SHA384", "2.16.840.1.101.3.4.2.3" => "SHA512", "1.3.14.3.2.26" => "SHA1", _ => oid }; private static byte[] ComputeHash(byte[] data, string algorithm) => algorithm switch { "SHA384" => SHA384.HashData(data), "SHA512" => SHA512.HashData(data), _ => SHA256.HashData(data) }; private static CadesLevel DetectCadesLevel(SignedCms signedCms) { var signerInfo = signedCms.SignerInfos[0]; var unsignedAttrs = signerInfo.UnsignedAttributes; const string archiveTimestampOid = "1.2.840.113549.1.9.16.2.48"; const string revocationValuesOid = "1.2.840.113549.1.9.16.2.24"; const string revocationRefsOid = "1.2.840.113549.1.9.16.2.22"; var hasArchiveTs = unsignedAttrs.Cast() .Any(a => a.Oid?.Value == archiveTimestampOid); if (hasArchiveTs) return CadesLevel.CadesLTA; var hasRevocationValues = unsignedAttrs.Cast() .Any(a => a.Oid?.Value == revocationValuesOid); if (hasRevocationValues) return CadesLevel.CadesLT; var hasRevocationRefs = unsignedAttrs.Cast() .Any(a => a.Oid?.Value == revocationRefsOid); if (hasRevocationRefs) return CadesLevel.CadesC; var hasSignatureTs = unsignedAttrs.Cast() .Any(a => a.Oid?.Value == SignatureTimestampOid); if (hasSignatureTs) return CadesLevel.CadesT; return CadesLevel.CadesB; } private static bool VerifyLtvCompleteness(SignedCms signedCms) { var signerInfo = signedCms.SignerInfos[0]; var unsignedAttrs = signerInfo.UnsignedAttributes; const string revocationValuesOid = "1.2.840.113549.1.9.16.2.24"; const string certValuesOid = "1.2.840.113549.1.9.16.2.23"; var hasRevocationValues = unsignedAttrs.Cast() .Any(a => a.Oid?.Value == revocationValuesOid); var hasCertValues = unsignedAttrs.Cast() .Any(a => a.Oid?.Value == certValuesOid); return hasRevocationValues && hasCertValues; } private static void ValidateCadesFormat(SignedCms signedCms, CadesLevel level, List errors) { var signerInfo = signedCms.SignerInfos[0]; var unsignedAttrs = signerInfo.UnsignedAttributes; const string archiveTimestampOid = "1.2.840.113549.1.9.16.2.48"; const string revocationValuesOid = "1.2.840.113549.1.9.16.2.24"; const string certValuesOid = "1.2.840.113549.1.9.16.2.23"; const string revocationRefsOid = "1.2.840.113549.1.9.16.2.22"; if (level >= CadesLevel.CadesT && !HasUnsignedAttribute(unsignedAttrs, SignatureTimestampOid)) { errors.Add("Missing signature timestamp attribute for CAdES-T"); } if (level >= CadesLevel.CadesC && !HasUnsignedAttribute(unsignedAttrs, revocationRefsOid)) { errors.Add("Missing revocation references for CAdES-C"); } if (level >= CadesLevel.CadesLT) { if (!HasUnsignedAttribute(unsignedAttrs, revocationValuesOid)) { errors.Add("Missing revocation values for CAdES-LT"); } if (!HasUnsignedAttribute(unsignedAttrs, certValuesOid)) { errors.Add("Missing certificate values for CAdES-LT"); } } if (level >= CadesLevel.CadesLTA && !HasUnsignedAttribute(unsignedAttrs, archiveTimestampOid)) { errors.Add("Missing archive timestamp for CAdES-LTA"); } } private static bool HasUnsignedAttribute(CryptographicAttributeObjectCollection attributes, string oid) { return attributes.Cast() .Any(a => a.Oid?.Value == oid); } private sealed class TstInfoData { public required string DigestAlgorithm { get; init; } public required byte[] MessageImprint { get; init; } public DateTimeOffset? GenerationTime { get; init; } } }