diff --git a/EnvelopeGenerator.Application/Common/Configurations/Pkcs11Options.cs b/EnvelopeGenerator.Application/Common/Configurations/Pkcs11Options.cs
index 5b89a9f4..5215cc71 100644
--- a/EnvelopeGenerator.Application/Common/Configurations/Pkcs11Options.cs
+++ b/EnvelopeGenerator.Application/Common/Configurations/Pkcs11Options.cs
@@ -91,9 +91,4 @@ public sealed class Pkcs11Options
/// true uses , false uses .
///
public bool UseUtcSigningTime { get; set; } = true;
-
- ///
- /// Optional global default certification level used when request-level options do not specify a value.
- ///
- public PdfSignatureCertificationLevel? DefaultCertificationLevel { get; set; }
}
diff --git a/EnvelopeGenerator.Application/Common/Dto/PdfSignOptionsDto.cs b/EnvelopeGenerator.Application/Common/Dto/PdfSignOptionsDto.cs
index 9b9ccf60..70bf3ac5 100644
--- a/EnvelopeGenerator.Application/Common/Dto/PdfSignOptionsDto.cs
+++ b/EnvelopeGenerator.Application/Common/Dto/PdfSignOptionsDto.cs
@@ -50,5 +50,5 @@ public sealed record PdfSignOptionsDto
///
///
///
- public PdfVisualSignatureOptionsDto VisualSignature { get; set; } = null!;
+ public PdfSignatureAppearanceDto? Appearance { get; set; }
}
diff --git a/EnvelopeGenerator.Application/Common/Dto/PdfSignatureAppearanceDto.cs b/EnvelopeGenerator.Application/Common/Dto/PdfSignatureAppearanceDto.cs
new file mode 100644
index 00000000..a3716d4e
--- /dev/null
+++ b/EnvelopeGenerator.Application/Common/Dto/PdfSignatureAppearanceDto.cs
@@ -0,0 +1,61 @@
+namespace EnvelopeGenerator.Application.Common.Dto;
+
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+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")
+{
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public sealed record FontDto(string FamilyName = "Helvetica", float Size = 10, bool IsBold = false, bool IsItalic = false, bool IsUnderline = false)
+ {
+ ///
+ ///
+ ///
+ public RgbaColorDto Color { get; init; } = new();
+ }
+
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public sealed record RgbaColorDto(double R = 0, double G = 0, double B = 0);
+
+ ///
+ ///
+ ///
+ public FontDto Font { get; init; } = new();
+}
diff --git a/EnvelopeGenerator.Application/Common/Dto/PdfVisualSignatureOptionsDto.cs b/EnvelopeGenerator.Application/Common/Dto/PdfVisualSignatureOptionsDto.cs
deleted file mode 100644
index 4716c1bb..00000000
--- a/EnvelopeGenerator.Application/Common/Dto/PdfVisualSignatureOptionsDto.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-namespace EnvelopeGenerator.Application.Common.Dto;
-
-///
-///
-///
-///
-///
-///
-///
-///
-///
-///
-public sealed record PdfVisualSignatureOptionsDto(
- byte[] ImageBytes,
- int PageNumber,
- double Left,
- double Bottom,
- double Width,
- double Height,
- bool ShowLabels = false);
diff --git a/EnvelopeGenerator.Application/Common/Enums/PdfSignatureCertificationLevel.cs b/EnvelopeGenerator.Application/Common/Enums/PdfSignatureCertificationLevel.cs
index f629465e..dc46493f 100644
--- a/EnvelopeGenerator.Application/Common/Enums/PdfSignatureCertificationLevel.cs
+++ b/EnvelopeGenerator.Application/Common/Enums/PdfSignatureCertificationLevel.cs
@@ -1,9 +1,27 @@
namespace EnvelopeGenerator.Application.Common.Enums;
+///
+///
+///
public enum PdfSignatureCertificationLevel
{
+ ///
+ /// All changes are allowed
+ ///
NoCertification = 0,
- NoChangesAllowed = 1,
- FormFillingAndSigning = 2,
- FormFillingAndAnnotations = 3
+
+ ///
+ /// No changes are allowed
+ ///
+ NoChangesAllowed,
+
+ ///
+ /// Allows users to fill in forms and sign the document.
+ ///
+ FillForms,
+
+ ///
+ /// Allows users to fill in forms, create, modify and delete annotations, and sign the document.
+ ///
+ FillFormsAndAnnotate
}
diff --git a/EnvelopeGenerator.Application/Common/Interfaces/Services/IPdfDigitalSignatureService.cs b/EnvelopeGenerator.Application/Common/Interfaces/Services/IPdfDigitalSignatureService.cs
index cd875279..db8ab3c5 100644
--- a/EnvelopeGenerator.Application/Common/Interfaces/Services/IPdfDigitalSignatureService.cs
+++ b/EnvelopeGenerator.Application/Common/Interfaces/Services/IPdfDigitalSignatureService.cs
@@ -14,18 +14,9 @@ public interface IPdfDigitalSignatureService
/// Source PDF content stream.
/// Destination stream for signed PDF output.
/// Optional signing options that map to PDF signature metadata fields.
+ /// Optional image stream for signature appearance.
/// Cancellation token for cooperative cancellation.
- Task SignPdfAsync(Stream inputPdf, Stream outputPdf, PdfSignOptionsDto? options = null, CancellationToken cancellationToken = default);
-
- ///
- /// Applies multiple signatures to the provided PDF in sequence and returns
- /// the final signed PDF document.
- ///
- /// Source PDF content stream.
- /// Destination stream for signed PDF output.
- /// Ordered signing options; one signature is applied per item.
- /// Cancellation token for cooperative cancellation.
- Task SignPdfAsync(Stream inputPdf, Stream outputPdf, IReadOnlyList optionsList, CancellationToken cancellationToken = default);
+ Task SignPdfAsync(Stream inputPdf, Stream outputPdf, PdfSignOptionsDto? options = null, Stream? image = null, CancellationToken cancellationToken = default);
///
/// Checks whether the provided PDF contains signature field(s) and whether
@@ -35,13 +26,4 @@ public interface IPdfDigitalSignatureService
/// Cancellation token for cooperative cancellation.
/// A structured result containing signature presence and per-signature details.
Task CheckPdfSignatureAsync(Stream inputPdf, CancellationToken cancellationToken = default);
-
- ///
- /// Extracts the original signed revision from a signed PDF.
- /// For multi-revision files this returns the first signed revision bytes.
- ///
- /// Signed PDF content stream.
- /// Destination stream for original revision output.
- /// Cancellation token for cooperative cancellation.
- Task ExtractOriginalPdfAsync(Stream inputPdf, Stream outputPdf, CancellationToken cancellationToken = default);
}
diff --git a/EnvelopeGenerator.Infrastructure.Crypt/Helper/PdfCertificationLevelExtensions.cs b/EnvelopeGenerator.Infrastructure.Crypt/Helper/PdfCertificationLevelExtensions.cs
new file mode 100644
index 00000000..c6c24250
--- /dev/null
+++ b/EnvelopeGenerator.Infrastructure.Crypt/Helper/PdfCertificationLevelExtensions.cs
@@ -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),
+ };
+}
\ No newline at end of file
diff --git a/EnvelopeGenerator.Infrastructure.Crypt/Services/PdfDigitalSignatureService.cs b/EnvelopeGenerator.Infrastructure.Crypt/Services/PdfDigitalSignatureService.cs
index 8aa933b0..844fa1e8 100644
--- a/EnvelopeGenerator.Infrastructure.Crypt/Services/PdfDigitalSignatureService.cs
+++ b/EnvelopeGenerator.Infrastructure.Crypt/Services/PdfDigitalSignatureService.cs
@@ -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 options)
{
private static readonly Regex SignatureReasonMetaRegex = new(@"\|\s*SFMETA:v1;doc=(?[0-9a-fA-F-]{36});sha256=(?[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 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 options)
}
}
- public async Task SignPdfAsync(Stream inputPdf, Stream outputPdf, IReadOnlyList 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 CheckPdfSignatureAsync(Stream inputPdf, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -135,7 +113,7 @@ public sealed class PdfDigitalSignatureService(IOptions 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 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 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 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();
diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/PdfSignatureController.cs b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/PdfSignatureController.cs
index f4c1b634..d35bbb86 100644
--- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/PdfSignatureController.cs
+++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/PdfSignatureController.cs
@@ -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
///
///
///
- public required IFormFile SignatureImageFile { get; init; }
+ public required IFormFile SignatureImage { get; init; }
///
///
///
- public required string? UUID { get; init; }
-
- ///
- ///
- ///
- public required string? Name { get; init; }
-
- ///
- ///
- ///
- public required string? Reason { get; init; }
-
- ///
- ///
- ///
- public required string? Location { get; init; }
-
- ///
- ///
- ///
- public required string? Email { get; init; }
-
- ///
- ///
- ///
- public required string? Phone { get; init; }
-
- ///
- ///
- ///
- public required VSignature Signature { get; init; }
-
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- 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();
}
///
@@ -99,32 +51,14 @@ public class PdfSignatureController(IPdfDigitalSignatureService signatureService
[Consumes("multipart/form-data")]
public async Task 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");