Add support for visual PDF signatures

Enhanced `PdfSignOptionsDto` with a `VisualSignature` property and introduced the `PdfVisualSignatureOptionsDto` class for configuring visual signature options. Updated `PdfDigitalSignatureService` to handle visual signatures, including page number resolution, signature bounds calculation, and appearance customization.

Refactored `PdfSignatureController` to support visual signatures by extending `SignPdfForm` with `SignatureImageFile` and `VSignature` properties. Improved validation for page numbers and signature dimensions. Streamlined `Sign` and `IsSigned` endpoints for better clarity and reduced redundancy.

Added XML documentation for improved API clarity and performed general code cleanup to enhance maintainability.
This commit is contained in:
2026-10-16 10:10:04 +02:00
parent 04cea1ca4b
commit 198eabcbae
4 changed files with 163 additions and 31 deletions

View File

@@ -12,4 +12,5 @@ public sealed record PdfSignOptionsDto
public string? Phone { get; set; }
public string? ApplicationName { get; set; }
public PdfSignatureCertificationLevel? CertificationLevel { get; set; }
public PdfVisualSignatureOptionsDto? VisualSignature { get; set; }
}

View File

@@ -0,0 +1,10 @@
namespace EnvelopeGenerator.Application.Common.Dto;
public sealed record PdfVisualSignatureOptionsDto(
byte[] ImageBytes,
int PageNumber,
double Left,
double Bottom,
double Width,
double Height,
bool ShowLabels = false);

View File

@@ -38,14 +38,19 @@ public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options)
var reasonWithMeta = BuildReasonWithMeta(request.Reason ?? pkcs11Options.DefaultReason, 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(1)
var signatureFieldInfo = new PdfSignatureFieldInfo(signaturePageNumber)
{
Name = $"Sig_{DateTimeOffset.UtcNow:yyyyMMddHHmmss}"
};
var signatureBounds = CreateSignatureBounds(request.VisualSignature);
if (signatureBounds is not null)
signatureFieldInfo.SignatureBounds = signatureBounds;
var builder = new PdfDeferredSignatureBuilder(signerInfo, signatureFieldInfo)
{
Name = request.Name ?? pkcs11Options.CertificateLabel,
@@ -59,6 +64,21 @@ public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options)
?? PdfCertificationLevel.NoCertification
};
if(request.VisualSignature is PdfVisualSignatureOptionsDto visualSignature)
{
builder.SetSignatureAppearance(new PdfSignatureAppearance
{
AppearanceType = PdfSignatureAppearanceType.Name,
ShowLabels = visualSignature.ShowLabels,
ShowName = true,
ShowDate = false,
ShowReason = false,
ShowLocation = false,
ShowDistinguishedName = false
});
builder.SetImageData(visualSignature.ImageBytes);
}
var deferredSigner = signer.SignDeferred(builder);
var externalSigner = new Pkcs11ExternalSigner(pkcs11Options);
deferredSigner.Sign(outputPdf, externalSigner.BuildSignature(deferredSigner.HashValue, digestCalculator.AlgorithmOid));
@@ -535,6 +555,37 @@ public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options)
: 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 PdfRectangle? CreateSignatureBounds(PdfVisualSignatureOptionsDto? visualOptions)
{
if (visualOptions is null)
return null;
var left = visualOptions.Left;
var bottom = visualOptions.Bottom;
var width = visualOptions.Width;
var height = visualOptions.Height;
if (left < 0 || bottom < 0)
throw new ArgumentOutOfRangeException(nameof(visualOptions), "VisualSignature.Left and VisualSignature.Bottom cannot be negative.");
if (width <= 0 || height <= 0)
throw new ArgumentOutOfRangeException(nameof(visualOptions), "VisualSignature.Width and VisualSignature.Height must be greater than zero.");
return new PdfRectangle(left, bottom, left + width, bottom + height);
}
private static string BuildReasonWithMeta(string? businessReason, string uuid, string sourceHashHex)
{
var trimmedReason = string.IsNullOrWhiteSpace(businessReason) ? null : businessReason.Trim();

View File

@@ -6,38 +6,102 @@ using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.Server.Controllers;
/// <summary>
///
/// </summary>
/// <param name="signatureService"></param>
[ApiController]
[Route("api/[controller]")]
public class PdfSignatureController(IPdfDigitalSignatureService signatureService) : ControllerBase
{
public sealed class SignPdfForm
/// <summary>
///
/// </summary>
public class CheckPdfSignatureForm
{
public IFormFile? File { get; set; }
public string? UUID { get; set; }
public string? Name { get; set; }
public string? Reason { get; set; }
public string? Location { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
/// <summary>
///
/// </summary>
public virtual required IFormFile File { get; init; }
}
public sealed class CheckPdfSignatureForm
/// <summary>
///
/// </summary>
public sealed class SignPdfForm : CheckPdfSignatureForm
{
public IFormFile? File { get; set; }
/// <summary>
///
/// </summary>
public required IFormFile SignatureImageFile { 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);
}
/// <summary>
///
/// </summary>
/// <param name="request"></param>
/// <param name="cancel"></param>
/// <returns></returns>
[AllowAnonymous]
[HttpPost("sign")]
[Consumes("multipart/form-data")]
public async Task<IActionResult> Sign([FromForm] SignPdfForm request, CancellationToken cancel)
{
var file = request.File;
if (file is null || file.Length == 0)
return BadRequest("PDF file is required.");
if (!file.FileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
return BadRequest("Only PDF files are supported.");
await using var signatureImageInput = request.SignatureImageFile.OpenReadStream();
using var signatureImageBuffer = new MemoryStream();
await signatureImageInput.CopyToAsync(signatureImageBuffer, cancel);
var signOptions = new PdfSignOptionsDto
{
@@ -47,31 +111,37 @@ public class PdfSignatureController(IPdfDigitalSignatureService signatureService
Location = request.Location,
Email = request.Email,
Phone = request.Phone,
CertificationLevel = PdfSignatureCertificationLevel.FormFillingAndAnnotations
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 input = file.OpenReadStream();
await using var output = new MemoryStream();
await using var input = request.File.OpenReadStream();
var output = new MemoryStream();
await signatureService.SignPdfAsync(input, output, signOptions, cancel);
output.Position = 0;
var fileName = Path.GetFileNameWithoutExtension(file.FileName);
var fileName = Path.GetFileNameWithoutExtension(request.File.FileName);
return File(output, "application/pdf", $"{fileName}-signed.pdf");
}
/// <summary>
///
/// </summary>
/// <param name="request"></param>
/// <param name="cancel"></param>
/// <returns></returns>
[AllowAnonymous]
[HttpPost("is-signed")]
[Consumes("multipart/form-data")]
public async Task<IActionResult> IsSigned([FromForm] CheckPdfSignatureForm request, CancellationToken cancel)
{
var file = request.File;
if (file is null || file.Length == 0)
return BadRequest("PDF file is required.");
if (!file.FileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
return BadRequest("Only PDF files are supported.");
await using var input = file.OpenReadStream();
await using var input = request.File.OpenReadStream();
var result = await signatureService.CheckPdfSignatureAsync(input, cancel);
return Ok(result);
}