Improve PDF signing and validation logic

Updated `.gitignore` to exclude new files and directories, including `FodyWeavers.xsd` and `appsettings.Secrets.json`.

Refactored `PdfDigitalSignatureService`:
- Improved signing time handling by calculating fallback inline.
- Simplified `SignPdfAsync` by inlining signature building.
- Added `ParseByteRanges` to extract `/ByteRange` values from PDFs.
- Introduced `CoversWholeDocument` to validate document coverage.

Enhanced signature validation:
- Integrated `ParseByteRanges` and `CoversWholeDocument`.
- Streamlined `PdfSignatureCheckItemDto` construction.
- Updated properties to calculate values inline.
- Improved `Unchanged` property to check cryptographic validity and document coverage.

These changes enhance maintainability, readability, and functionality.
This commit is contained in:
2026-10-13 18:52:28 +02:00
parent 1942d78155
commit c8ddfac8d0
2 changed files with 73 additions and 24 deletions

View File

@@ -10,6 +10,7 @@ using System.Security.Cryptography.Pkcs;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.RegularExpressions;
namespace EnvelopeGenerator.Infrastructure.Crypt.Services;
@@ -20,7 +21,6 @@ public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options)
ArgumentNullException.ThrowIfNull(pdfBytes);
var pkcs11Options = options.Value;
var request = optionsModel ?? new PdfSignOptionsDto();
var fallbackSigningTime = pkcs11Options.UseUtcSigningTime ? DateTimeOffset.UtcNow : DateTimeOffset.Now;
using var inputPdf = new MemoryStream(pdfBytes);
using var signer = new PdfDocumentSigner(inputPdf);
@@ -38,7 +38,7 @@ public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options)
Reason = request.Reason ?? pkcs11Options.DefaultReason,
Location = request.Location,
ContactInfo = request.ContactInfo,
SigningTime = request.SigningTime ?? fallbackSigningTime,
SigningTime = request.SigningTime ?? (pkcs11Options.UseUtcSigningTime ? DateTimeOffset.UtcNow : DateTimeOffset.Now),
ApplicationName = request.ApplicationName ?? pkcs11Options.DefaultApplicationName,
CertificationLevel = ToDevExpressCertificationLevel(request.CertificationLevel)
?? ToDevExpressCertificationLevel(pkcs11Options.DefaultCertificationLevel)
@@ -47,10 +47,9 @@ public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options)
var deferredSigner = signer.SignDeferred(builder);
var externalSigner = new Pkcs11ExternalSigner(pkcs11Options);
byte[] signature = externalSigner.BuildSignature(deferredSigner.HashValue, digestCalculator.AlgorithmOid);
using var outputPdf = new MemoryStream();
deferredSigner.Sign(outputPdf, signature);
deferredSigner.Sign(outputPdf, externalSigner.BuildSignature(deferredSigner.HashValue, digestCalculator.AlgorithmOid));
return Task.FromResult(outputPdf.ToArray());
}
@@ -80,6 +79,7 @@ public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options)
using var inputPdf = new MemoryStream(pdfBytes);
using var signer = new PdfDocumentSigner(inputPdf);
var signatures = signer.GetSignatureInfo();
var byteRanges = ParseByteRanges(pdfBytes);
if (signatures is null || signatures.Count == 0)
{
@@ -90,25 +90,18 @@ public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options)
var items = new List<PdfSignatureCheckItemDto>(signatures.Count);
foreach (var signature in signatures)
for (var i = 0; i < signatures.Count; i++)
{
var signature = signatures[i];
try
{
var pkcs7 = signer.GetPdfPkcs7Signature(signature.FieldName);
bool valid = pkcs7.VerifySignature();
bool coversWholeDocument = true;
var byteRange = i < byteRanges.Count ? byteRanges[i] : null;
var coversWholeDocument = CoversWholeDocument(byteRange, pdfBytes.LongLength);
var certificate = pkcs7.GetSignatureCertificate();
var now = DateTimeOffset.UtcNow;
var serialNumber = ToColonSerial(certificate?.SerialNumber);
DateTimeOffset? validFrom = certificate is null ? null : new DateTimeOffset(certificate.NotBefore).ToUniversalTime();
DateTimeOffset? validTo = certificate is null ? null : new DateTimeOffset(certificate.NotAfter).ToUniversalTime();
var issuedTo = ParsePrincipal(certificate?.Subject);
var issuedBy = ParsePrincipal(certificate?.Issuer);
var certificateInfo = BuildCertificateInfo(certificate, now);
var digestAlgorithm = InferDigestAlgorithm(certificate);
var signatureAlgorithm = InferSignatureAlgorithm(certificate);
var signerDisplayName = issuedTo.CommonName ?? signature.SignerName;
var messageDigestHex = TryExtractMessageDigestHex(signature.Contents);
items.Add(new PdfSignatureCheckItemDto(
CoversWholeDoc: coversWholeDocument,
@@ -116,23 +109,23 @@ public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options)
Location: signature.Location,
DeclaredName: signature.SignerName,
ContactInfo: signature.ContactInfo,
DigestAlgorithm: digestAlgorithm,
SignatureAlgorithm: signatureAlgorithm,
SignerName: signerDisplayName,
DigestAlgorithm: InferDigestAlgorithm(certificate),
SignatureAlgorithm: InferSignatureAlgorithm(certificate),
SignerName: issuedTo.CommonName ?? signature.SignerName,
Issuer: issuedBy.Full,
SerialNumber: serialNumber,
ValidFrom: validFrom,
ValidTo: validTo,
SerialNumber: ToColonSerial(certificate?.SerialNumber),
ValidFrom: certificate is null ? null : new DateTimeOffset(certificate.NotBefore).ToUniversalTime(),
ValidTo: certificate is null ? null : new DateTimeOffset(certificate.NotAfter).ToUniversalTime(),
ExpiryCheckedAgainst: "now",
Certificates: [certificateInfo],
Unchanged: valid,
Certificates: [BuildCertificateInfo(certificate, DateTimeOffset.UtcNow)],
Unchanged: valid && coversWholeDocument,
FieldName: signature.FieldName,
IsCryptographicallyValid: valid,
SigningTime: signature.SigningTime?.ToUniversalTime().ToString("O"),
Filter: signature.Filter,
SubFilter: signature.SubFilter,
ApplicationName: null,
MessageDigestHex: messageDigestHex,
MessageDigestHex: TryExtractMessageDigestHex(signature.Contents),
Error: null));
}
catch (Exception ex)
@@ -350,4 +343,59 @@ public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options)
return null;
}
}
private static List<long[]> ParseByteRanges(byte[] pdfBytes)
{
var text = Encoding.Latin1.GetString(pdfBytes);
var matches = Regex.Matches(text, @"/ByteRange\s*\[(?<values>[\d\s]+)\]", RegexOptions.CultureInvariant);
var ranges = new List<long[]>(matches.Count);
foreach (Match match in matches)
{
var raw = match.Groups["values"].Value;
var parts = raw.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
var values = new long[parts.Length];
var ok = true;
for (var i = 0; i < parts.Length; i++)
{
if (!long.TryParse(parts[i], out values[i]))
{
ok = false;
break;
}
}
if (ok && values.Length >= 4 && values.Length % 2 == 0)
ranges.Add(values);
}
return ranges;
}
private static bool CoversWholeDocument(long[]? byteRange, long fileLength)
{
if (byteRange is null || byteRange.Length < 4 || byteRange.Length % 2 != 0)
return false;
if (byteRange[0] != 0)
return false;
long previousEnd = -1;
for (var i = 0; i < byteRange.Length; i += 2)
{
var start = byteRange[i];
var length = byteRange[i + 1];
if (start < 0 || length < 0)
return false;
var end = start + length;
if (previousEnd > start)
return false;
previousEnd = end;
}
return previousEnd == fileLength;
}
}