Refactor PDF signing logic and appearance handling

Refactored `Pkcs11Options` by removing `DefaultCertificationLevel` and improving property documentation. Replaced `VisualSignature` in `PdfSignOptionsDto` with `Appearance` using the new `PdfSignatureAppearanceDto` class, which encapsulates signature appearance details, including font and color settings.

Enhanced `PdfSignatureCertificationLevel` enum with more descriptive values and added XML documentation. Updated `IPdfDigitalSignatureService` to support optional signature images and removed redundant methods.

Refactored `PdfDigitalSignatureService` to streamline appearance handling, validate inputs, and improve maintainability. Removed unused methods and added support for `PdfSignatureAppearanceDto`.

Simplified `PdfSignatureController` by consolidating form properties into `PdfSignOptionsDto`. Improved code readability and flexibility while removing redundant logic.
This commit is contained in:
2026-10-19 10:48:06 +02:00
parent 57a08e8a89
commit 232b341894
9 changed files with 170 additions and 255 deletions

View File

@@ -0,0 +1,16 @@
using DevExpress.Pdf;
using EnvelopeGenerator.Application.Common.Enums;
namespace EnvelopeGenerator.Infrastructure.Crypt.Helper;
public static class PdfCertificationLevelExtensions
{
public static PdfCertificationLevel ToDevExpress(this PdfSignatureCertificationLevel value) => value switch
{
PdfSignatureCertificationLevel.NoChangesAllowed => PdfCertificationLevel.NoChangesAllowed,
PdfSignatureCertificationLevel.FillForms => PdfCertificationLevel.FillForms,
PdfSignatureCertificationLevel.NoCertification => PdfCertificationLevel.NoCertification,
PdfSignatureCertificationLevel.FillFormsAndAnnotate => PdfCertificationLevel.FillFormsAndAnnotate,
_ => throw new ArgumentOutOfRangeException(nameof(value), value, null),
};
}

View File

@@ -1,13 +1,15 @@
using EnvelopeGenerator.Application.Common.Configurations;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Enums;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using DevExpress.Office.DigitalSignatures;
using DevExpress.Pdf;
using DigitalData.Core.Exceptions;
using EnvelopeGenerator.Application.Common.Configurations;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using EnvelopeGenerator.Infrastructure.Crypt.Helper;
using Microsoft.Extensions.Options;
using System.Formats.Asn1;
using System.Security.Cryptography.Pkcs;
using System.IO;
using System.Security.Cryptography;
using System.Security.Cryptography.Pkcs;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.RegularExpressions;
@@ -18,7 +20,7 @@ public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options)
{
private static readonly Regex SignatureReasonMetaRegex = new(@"\|\s*SFMETA:v1;doc=(?<doc>[0-9a-fA-F-]{36});sha256=(?<sha>[A-Fa-f0-9]{64})$", RegexOptions.CultureInvariant | RegexOptions.Compiled);
public async Task SignPdfAsync(Stream inputPdf, Stream outputPdf, PdfSignOptionsDto request, CancellationToken cancellationToken = default)
public async Task SignPdfAsync(Stream inputPdf, Stream outputPdf, PdfSignOptionsDto request, Stream? image = null, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -32,48 +34,66 @@ public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options)
var pkcs11Options = options.Value;
var uuid = ResolveUuid(request.UUID);
var reasonWithMeta = BuildReasonWithMeta(request.Reason ?? pkcs11Options.DefaultReason, uuid, sourceHashHex);
var reasonWithMeta = BuildReasonWithMeta(request.Reason ?? pkcs11Options.DefaultReason, request.UUID, sourceHashHex);
using var signer = new PdfDocumentSigner(workingInput);
var signaturePageNumber = ResolveSignaturePageNumber(request.VisualSignature?.PageNumber, signer.PageCount);
var digestCalculator = new DigestCalculator(HashAlgorithmType.SHA256);
var signerInfo = new ExternalSignerInfo(PdfSignatureType.Pkcs7, 32768, digestCalculator);
var signatureFieldInfo = new PdfSignatureFieldInfo(signaturePageNumber)
{
Name = $"Sig_{DateTimeOffset.UtcNow:yyyyMMddHHmmss}"
};
var left = request.VisualSignature!.Left;
var bottom = request.VisualSignature.Bottom;
signatureFieldInfo.SignatureBounds = new PdfRectangle(left, bottom, left + request.VisualSignature.Width, bottom + request.VisualSignature.Height);
var builder = new PdfDeferredSignatureBuilder(signerInfo, signatureFieldInfo)
var builder = request.Appearance is null ? new PdfDeferredSignatureBuilder(signerInfo) : new (signerInfo, new PdfSignatureFieldInfo(request.Appearance.PageNumber)
{
Name = request.Name ?? pkcs11Options.CertificateLabel,
Reason = reasonWithMeta,
Location = request.Location,
ContactInfo = BuildContactInfo(request.Email, request.Phone),
SigningTime = pkcs11Options.UseUtcSigningTime ? DateTimeOffset.UtcNow : DateTimeOffset.Now,
ApplicationName = pkcs11Options.ApplicationName,
CertificationLevel = ToDevExpressCertificationLevel(request.CertificationLevel)
?? ToDevExpressCertificationLevel(pkcs11Options.DefaultCertificationLevel)
?? PdfCertificationLevel.NoCertification
};
builder.SetSignatureAppearance(new PdfSignatureAppearance
{
AppearanceType = PdfSignatureAppearanceType.Name,
ShowLabels = request.VisualSignature.ShowLabels,
ShowName = true,
ShowDate = false,
ShowReason = false,
ShowLocation = false,
ShowDistinguishedName = false
Name = $"Sig_{DateTimeOffset.UtcNow:yyyyMMddHHmmss}",
SignatureBounds = new PdfRectangle(request.Appearance.Left, request.Appearance.Bottom, request.Appearance.Left + request.Appearance.Width, request.Appearance.Bottom + request.Appearance.Height)
});
builder.SetImageData(request.VisualSignature.ImageBytes);
builder.Name = request.Name;
builder.Reason = reasonWithMeta;
builder.Location = request.Location;
builder.ContactInfo = BuildContactInfo(request.Email, request.Phone);
builder.SigningTime = pkcs11Options.UseUtcSigningTime ? DateTimeOffset.UtcNow : DateTimeOffset.Now;
builder.ApplicationName = pkcs11Options.ApplicationName;
builder.CertificationLevel = request.CertificationLevel.ToDevExpress();
if (request.Appearance is not null)
{
var fColor = request.Appearance.Font.Color;
var font = new PdfSignatureFontSettings(request.Appearance.Font.FamilyName)
{
Size = request.Appearance.Font.Size,
Color = new PdfRGBColor(fColor.R / 255.0, fColor.G / 255.0, fColor.B / 255.0),
Bold = false,
Italic = true
};
var appearance = new PdfSignatureAppearance
{
ShowLabels = request.Appearance.ShowLabels,
ShowName = request.Appearance.ShowName,
ShowDate = request.Appearance.ShowDate,
ShowReason = request.Appearance.ShowReason,
ShowLocation = request.Appearance.ShowLocation,
ShowDistinguishedName = request.Appearance.ShowDistinguishedName,
DateTimeFormat = request.Appearance.DateTimeFormat,
SignatureDetailsFont = font,
SignatureNameFont = font,
AppearanceType = PdfSignatureAppearanceType.Image,
};
if (image is not null)
using (var memoryStream = new MemoryStream())
{
image.CopyTo(memoryStream);
var bytes = memoryStream.ToArray();
appearance.SetBackgroundImageData(bytes);
}
builder.SetSignatureAppearance(appearance);
}
else if (image is not null)
throw new BadRequestException("Signature appearance is not specified, but signature image is provided.");
var deferredSigner = signer.SignDeferred(builder);
var externalSigner = new Pkcs11ExternalSigner(pkcs11Options);
deferredSigner.Sign(outputPdf, externalSigner.BuildSignature(deferredSigner.HashValue, digestCalculator.AlgorithmOid));
@@ -85,49 +105,7 @@ public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options)
}
}
public async Task SignPdfAsync(Stream inputPdf, Stream outputPdf, IReadOnlyList<PdfSignOptionsDto> optionsList, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (optionsList.Count == 0)
{
await inputPdf.CopyToAsync(outputPdf, cancellationToken);
return;
}
var (currentInput, ownsCurrentInput) = await EnsureSeekableInputAsync(inputPdf, cancellationToken);
try
{
for (var i = 0; i < optionsList.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var isLast = i == optionsList.Count - 1;
if (isLast)
{
await SignPdfAsync(currentInput, outputPdf, optionsList[i], cancellationToken);
continue;
}
var intermediateOutput = new MemoryStream();
await SignPdfAsync(currentInput, intermediateOutput, optionsList[i], cancellationToken);
if (ownsCurrentInput)
await currentInput.DisposeAsync();
intermediateOutput.Position = 0;
currentInput = intermediateOutput;
ownsCurrentInput = true;
}
}
finally
{
if (ownsCurrentInput)
await currentInput.DisposeAsync();
}
}
//TODO: verify and optimize (remove memory stream etc.) the check algortihm
public async Task<PdfSignatureCheckResultDto> CheckPdfSignatureAsync(Stream inputPdf, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -135,7 +113,7 @@ public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options)
var pdfBytes = await ReadAllBytesAsync(inputPdf, cancellationToken);
using var pdfStream = new MemoryStream(pdfBytes);
using var signer = new PdfDocumentSigner(pdfStream);
using var signer = new PdfDocumentSigner(inputPdf);
var signatures = signer.GetSignatureInfo();
var byteRanges = ParseByteRanges(pdfBytes);
@@ -235,31 +213,6 @@ public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options)
Signatures: items);
}
public async Task ExtractOriginalPdfAsync(Stream inputPdf, Stream outputPdf, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var pdfBytes = await ReadAllBytesAsync(inputPdf, cancellationToken);
var byteRanges = ParseByteRanges(pdfBytes);
if (byteRanges.Count == 0)
{
await outputPdf.WriteAsync(pdfBytes, cancellationToken);
return;
}
var revisionLength = byteRanges
.Select(GetSignedRevisionLength)
.Where(x => x > 0)
.DefaultIfEmpty(pdfBytes.LongLength)
.Min();
if (revisionLength > pdfBytes.LongLength)
throw new InvalidOperationException("Invalid ByteRange in signed PDF.");
await outputPdf.WriteAsync(pdfBytes.AsMemory(0, (int)revisionLength), cancellationToken);
}
private static async Task<(Stream Stream, bool OwnsStream)> EnsureSeekableInputAsync(Stream input, CancellationToken cancellationToken)
{
if (input.CanSeek)
@@ -398,11 +351,6 @@ public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options)
return new PdfSignaturePrincipalDto(commonName, organization, country, dn);
}
private static PdfCertificationLevel? ToDevExpressCertificationLevel(PdfSignatureCertificationLevel? value)
{
return value is null ? null : (PdfCertificationLevel)(int)value.Value;
}
private static string? ToColonSerial(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
@@ -544,25 +492,6 @@ public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options)
return start + length;
}
private static string ResolveUuid(string? uuid)
{
return Guid.TryParse(uuid, out var parsed)
? parsed.ToString()
: Guid.NewGuid().ToString();
}
private static int ResolveSignaturePageNumber(int? requestedPageNumber, int pageCount)
{
var pageNumber = requestedPageNumber ?? 1;
if (pageNumber < 1)
throw new ArgumentOutOfRangeException(nameof(requestedPageNumber), "SignaturePageNumber must be greater than or equal to 1.");
if (pageCount > 0 && pageNumber > pageCount)
throw new ArgumentOutOfRangeException(nameof(requestedPageNumber), $"SignaturePageNumber {pageNumber} exceeds page count {pageCount}.");
return pageNumber;
}
private static string BuildReasonWithMeta(string? businessReason, string uuid, string sourceHashHex)
{
var trimmedReason = string.IsNullOrWhiteSpace(businessReason) ? null : businessReason.Trim();