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

@@ -91,9 +91,4 @@ public sealed class Pkcs11Options
/// <c>true</c> uses <see cref="DateTimeOffset.UtcNow"/>, <c>false</c> uses <see cref="DateTimeOffset.Now"/>.
/// </summary>
public bool UseUtcSigningTime { get; set; } = true;
/// <summary>
/// Optional global default certification level used when request-level options do not specify a value.
/// </summary>
public PdfSignatureCertificationLevel? DefaultCertificationLevel { get; set; }
}

View File

@@ -50,5 +50,5 @@ public sealed record PdfSignOptionsDto
/// <summary>
///
/// </summary>
public PdfVisualSignatureOptionsDto VisualSignature { get; set; } = null!;
public PdfSignatureAppearanceDto? Appearance { get; set; }
}

View File

@@ -0,0 +1,61 @@
namespace EnvelopeGenerator.Application.Common.Dto;
/// <summary>
///
/// </summary>
/// <param name="ImageBytes"></param>
/// <param name="PageNumber"></param>
/// <param name="Left"></param>
/// <param name="Bottom"></param>
/// <param name="Width"></param>
/// <param name="Height"></param>
/// <param name="ShowLabels"></param>
/// <param name="ShowName"></param>
/// <param name="ShowDate"></param>
/// <param name="ShowReason"></param>
/// <param name="ShowLocation"></param>
/// <param name="ShowDistinguishedName"></param>
/// <param name="DateTimeFormat"></param>
public sealed record PdfSignatureAppearanceDto(
int PageNumber,
double Left,
double Bottom,
double Width,
double Height,
bool ShowLabels = false,
bool ShowName = false,
bool ShowDate = false,
bool ShowReason = false,
bool ShowLocation = false,
bool ShowDistinguishedName = false,
string DateTimeFormat = "dd.MM.yyyy HH:mm:ss zzz")
{
/// <summary>
///
/// </summary>
/// <param name="FamilyName"></param>
/// <param name="Size"></param>
/// <param name="IsBold"></param>
/// <param name="IsItalic"></param>
/// <param name="IsUnderline"></param>
public sealed record FontDto(string FamilyName = "Helvetica", float Size = 10, bool IsBold = false, bool IsItalic = false, bool IsUnderline = false)
{
/// <summary>
///
/// </summary>
public RgbaColorDto Color { get; init; } = new();
}
/// <summary>
///
/// </summary>
/// <param name="R"></param>
/// <param name="G"></param>
/// <param name="B"></param>
public sealed record RgbaColorDto(double R = 0, double G = 0, double B = 0);
/// <summary>
///
/// </summary>
public FontDto Font { get; init; } = new();
}

View File

@@ -1,20 +0,0 @@
namespace EnvelopeGenerator.Application.Common.Dto;
/// <summary>
///
/// </summary>
/// <param name="ImageBytes"></param>
/// <param name="PageNumber"></param>
/// <param name="Left"></param>
/// <param name="Bottom"></param>
/// <param name="Width"></param>
/// <param name="Height"></param>
/// <param name="ShowLabels"></param>
public sealed record PdfVisualSignatureOptionsDto(
byte[] ImageBytes,
int PageNumber,
double Left,
double Bottom,
double Width,
double Height,
bool ShowLabels = false);

View File

@@ -1,9 +1,27 @@
namespace EnvelopeGenerator.Application.Common.Enums;
/// <summary>
///
/// </summary>
public enum PdfSignatureCertificationLevel
{
/// <summary>
/// All changes are allowed
/// </summary>
NoCertification = 0,
NoChangesAllowed = 1,
FormFillingAndSigning = 2,
FormFillingAndAnnotations = 3
/// <summary>
/// No changes are allowed
/// </summary>
NoChangesAllowed,
/// <summary>
/// Allows users to fill in forms and sign the document.
/// </summary>
FillForms,
/// <summary>
/// Allows users to fill in forms, create, modify and delete annotations, and sign the document.
/// </summary>
FillFormsAndAnnotate
}

View File

@@ -14,18 +14,9 @@ public interface IPdfDigitalSignatureService
/// <param name="inputPdf">Source PDF content stream.</param>
/// <param name="outputPdf">Destination stream for signed PDF output.</param>
/// <param name="options">Optional signing options that map to PDF signature metadata fields.</param>
/// <param name="image">Optional image stream for signature appearance.</param>
/// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param>
Task SignPdfAsync(Stream inputPdf, Stream outputPdf, PdfSignOptionsDto? options = null, CancellationToken cancellationToken = default);
/// <summary>
/// Applies multiple signatures to the provided PDF in sequence and returns
/// the final signed PDF document.
/// </summary>
/// <param name="inputPdf">Source PDF content stream.</param>
/// <param name="outputPdf">Destination stream for signed PDF output.</param>
/// <param name="optionsList">Ordered signing options; one signature is applied per item.</param>
/// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param>
Task SignPdfAsync(Stream inputPdf, Stream outputPdf, IReadOnlyList<PdfSignOptionsDto> optionsList, CancellationToken cancellationToken = default);
Task SignPdfAsync(Stream inputPdf, Stream outputPdf, PdfSignOptionsDto? options = null, Stream? image = null, CancellationToken cancellationToken = default);
/// <summary>
/// Checks whether the provided PDF contains signature field(s) and whether
@@ -35,13 +26,4 @@ public interface IPdfDigitalSignatureService
/// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param>
/// <returns>A structured result containing signature presence and per-signature details.</returns>
Task<PdfSignatureCheckResultDto> CheckPdfSignatureAsync(Stream inputPdf, CancellationToken cancellationToken = default);
/// <summary>
/// Extracts the original signed revision from a signed PDF.
/// For multi-revision files this returns the first signed revision bytes.
/// </summary>
/// <param name="inputPdf">Signed PDF content stream.</param>
/// <param name="outputPdf">Destination stream for original revision output.</param>
/// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param>
Task ExtractOriginalPdfAsync(Stream inputPdf, Stream outputPdf, CancellationToken cancellationToken = default);
}

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();

View File

@@ -1,5 +1,4 @@
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Enums;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -33,59 +32,12 @@ public class PdfSignatureController(IPdfDigitalSignatureService signatureService
/// <summary>
///
/// </summary>
public required IFormFile SignatureImageFile { get; init; }
public required IFormFile SignatureImage { get; init; }
/// <summary>
///
/// </summary>
public required string? UUID { get; init; }
/// <summary>
///
/// </summary>
public required string? Name { get; init; }
/// <summary>
///
/// </summary>
public required string? Reason { get; init; }
/// <summary>
///
/// </summary>
public required string? Location { get; init; }
/// <summary>
///
/// </summary>
public required string? Email { get; init; }
/// <summary>
///
/// </summary>
public required string? Phone { get; init; }
/// <summary>
///
/// </summary>
public required VSignature Signature { get; init; }
/// <summary>
///
/// </summary>
/// <param name="PageNumber"></param>
/// <param name="Left"></param>
/// <param name="Bottom"></param>
/// <param name="Width"></param>
/// <param name="Height"></param>
/// <param name="ShowLabels"></param>
public record VSignature(
int PageNumber = 1,
double Left = 100,
double Bottom = 100,
double Width = 220,
double Height = 80,
bool ShowLabels = true);
public PdfSignOptionsDto Options { get; init; } = new PdfSignOptionsDto();
}
/// <summary>
@@ -99,32 +51,14 @@ public class PdfSignatureController(IPdfDigitalSignatureService signatureService
[Consumes("multipart/form-data")]
public async Task<IActionResult> Sign([FromForm] SignPdfForm request, CancellationToken cancel)
{
await using var signatureImageInput = request.SignatureImageFile.OpenReadStream();
using var signatureImageBuffer = new MemoryStream();
await signatureImageInput.CopyToAsync(signatureImageBuffer, cancel);
var signOptions = new PdfSignOptionsDto
{
UUID = request.UUID,
Name = request.Name,
Reason = request.Reason,
Location = request.Location,
Email = request.Email,
Phone = request.Phone,
CertificationLevel = PdfSignatureCertificationLevel.FormFillingAndAnnotations,
VisualSignature = new PdfVisualSignatureOptionsDto(
ImageBytes: signatureImageBuffer.ToArray(),
PageNumber: request.Signature.PageNumber,
Left: request.Signature.Left,
Bottom: request.Signature.Bottom,
Width: request.Signature.Width,
Height: request.Signature.Height,
ShowLabels: request.Signature.ShowLabels)
};
await using var signatureImage = request.SignatureImage.OpenReadStream();
await using var input = request.File.OpenReadStream();
var output = new MemoryStream();
await signatureService.SignPdfAsync(input, output, signOptions, cancel);
await signatureService.SignPdfAsync(input, output, request.Options, signatureImage, cancel);
output.Position = 0;
var fileName = Path.GetFileNameWithoutExtension(request.File.FileName);
return File(output, "application/pdf", $"{fileName}-signed.pdf");