404 lines
15 KiB
C#
404 lines
15 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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;
|
|
|
|
/// <summary>
|
|
/// Implementation of <see cref="IQualifiedTimestampVerifier"/>.
|
|
/// </summary>
|
|
public sealed class QualifiedTimestampVerifier : IQualifiedTimestampVerifier
|
|
{
|
|
private readonly IEuTrustListService _trustListService;
|
|
private readonly ILogger<QualifiedTimestampVerifier> _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";
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="QualifiedTimestampVerifier"/> class.
|
|
/// </summary>
|
|
public QualifiedTimestampVerifier(
|
|
IEuTrustListService trustListService,
|
|
ILogger<QualifiedTimestampVerifier> logger)
|
|
{
|
|
_trustListService = trustListService;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<QualifiedTimestampVerificationResult> VerifyAsync(
|
|
ReadOnlyMemory<byte> timestampToken,
|
|
ReadOnlyMemory<byte> originalData,
|
|
QualifiedTimestampVerificationOptions? options = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
options ??= new QualifiedTimestampVerificationOptions();
|
|
var errors = new List<string>();
|
|
var warnings = new List<string>();
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<CadesVerificationResult> VerifyCadesAsync(
|
|
ReadOnlyMemory<byte> signature,
|
|
ReadOnlyMemory<byte> originalData,
|
|
QualifiedTimestampVerificationOptions? options = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
options ??= new QualifiedTimestampVerificationOptions();
|
|
var errors = new List<string>();
|
|
var warnings = new List<string>();
|
|
|
|
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<CryptographicAttributeObject>()
|
|
.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<CryptographicAttributeObject>()
|
|
.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<CryptographicAttributeObject>()
|
|
.Any(a => a.Oid?.Value == archiveTimestampOid);
|
|
if (hasArchiveTs) return CadesLevel.CadesLTA;
|
|
|
|
var hasRevocationValues = unsignedAttrs.Cast<CryptographicAttributeObject>()
|
|
.Any(a => a.Oid?.Value == revocationValuesOid);
|
|
if (hasRevocationValues) return CadesLevel.CadesLT;
|
|
|
|
var hasRevocationRefs = unsignedAttrs.Cast<CryptographicAttributeObject>()
|
|
.Any(a => a.Oid?.Value == revocationRefsOid);
|
|
if (hasRevocationRefs) return CadesLevel.CadesC;
|
|
|
|
var hasSignatureTs = unsignedAttrs.Cast<CryptographicAttributeObject>()
|
|
.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<CryptographicAttributeObject>()
|
|
.Any(a => a.Oid?.Value == revocationValuesOid);
|
|
var hasCertValues = unsignedAttrs.Cast<CryptographicAttributeObject>()
|
|
.Any(a => a.Oid?.Value == certValuesOid);
|
|
|
|
return hasRevocationValues && hasCertValues;
|
|
}
|
|
|
|
private static void ValidateCadesFormat(SignedCms signedCms, CadesLevel level, List<string> 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<CryptographicAttributeObject>()
|
|
.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; }
|
|
}
|
|
}
|