Files
EnvelopeGenerator/EnvelopeGenerator.Infrastructure.Crypt/Services/PdfDigitalSignatureService.cs
TekH 232b341894 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.
2026-10-19 10:48:06 +02:00

575 lines
22 KiB
C#

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.IO;
using System.Security.Cryptography;
using System.Security.Cryptography.Pkcs;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.RegularExpressions;
namespace EnvelopeGenerator.Infrastructure.Crypt.Services;
public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options) : IPdfDigitalSignatureService
{
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, Stream? image = null, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var (workingInput, ownsWorkingInput) = await EnsureSeekableInputAsync(inputPdf, cancellationToken);
try
{
workingInput.Position = 0;
var sourceHashHex = Convert.ToHexString(await SHA256.HashDataAsync(workingInput, cancellationToken));
workingInput.Position = 0;
var pkcs11Options = options.Value;
var reasonWithMeta = BuildReasonWithMeta(request.Reason ?? pkcs11Options.DefaultReason, request.UUID, sourceHashHex);
using var signer = new PdfDocumentSigner(workingInput);
var digestCalculator = new DigestCalculator(HashAlgorithmType.SHA256);
var signerInfo = new ExternalSignerInfo(PdfSignatureType.Pkcs7, 32768, digestCalculator);
var builder = request.Appearance is null ? new PdfDeferredSignatureBuilder(signerInfo) : new (signerInfo, new PdfSignatureFieldInfo(request.Appearance.PageNumber)
{
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.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));
}
finally
{
if (ownsWorkingInput)
await workingInput.DisposeAsync();
}
}
//TODO: verify and optimize (remove memory stream etc.) the check algortihm
public async Task<PdfSignatureCheckResultDto> CheckPdfSignatureAsync(Stream inputPdf, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var pdfBytes = await ReadAllBytesAsync(inputPdf, cancellationToken);
using var pdfStream = new MemoryStream(pdfBytes);
using var signer = new PdfDocumentSigner(inputPdf);
var signatures = signer.GetSignatureInfo();
var byteRanges = ParseByteRanges(pdfBytes);
if (signatures is null || signatures.Count == 0)
{
return new PdfSignatureCheckResultDto(
Signed: false,
Signatures: []);
}
var items = new List<PdfSignatureCheckItemDto>(signatures.Count);
for (var i = 0; i < signatures.Count; i++)
{
var signature = signatures[i];
try
{
var pkcs7 = signer.GetPdfPkcs7Signature(signature.FieldName);
bool valid = pkcs7.VerifySignature();
var byteRange = i < byteRanges.Count ? byteRanges[i] : null;
var coversWholeDocument = CoversWholeDocument(byteRange, pdfBytes.LongLength);
var certificate = pkcs7.GetSignatureCertificate();
var issuedTo = ParsePrincipal(certificate?.Subject);
var issuedBy = ParsePrincipal(certificate?.Issuer);
var reasonMeta = ParseReasonMeta(signature.Reason);
var contact = ParseContactInfo(signature.ContactInfo);
items.Add(new PdfSignatureCheckItemDto(
CoversWholeDoc: coversWholeDocument,
Reason: signature.Reason,
BusinessReason: reasonMeta.BusinessReason,
Location: signature.Location,
DeclaredName: signature.SignerName,
ContactInfo: signature.ContactInfo,
Email: contact.Email,
Phone: contact.Phone,
DigestAlgorithm: InferDigestAlgorithm(certificate),
SignatureAlgorithm: InferSignatureAlgorithm(certificate),
SignerName: issuedTo.CommonName ?? signature.SignerName,
Issuer: issuedBy.Full,
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: [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,
UUID: reasonMeta.UUID,
OriginalDocumentSha256: reasonMeta.OriginalDocumentSha256,
MessageDigestHex: TryExtractMessageDigestHex(signature.Contents),
Error: null));
}
catch (Exception ex)
{
var reasonMeta = ParseReasonMeta(signature.Reason);
var contact = ParseContactInfo(signature.ContactInfo);
items.Add(new PdfSignatureCheckItemDto(
CoversWholeDoc: false,
Reason: signature.Reason,
BusinessReason: reasonMeta.BusinessReason,
Location: signature.Location,
DeclaredName: signature.SignerName,
ContactInfo: signature.ContactInfo,
Email: contact.Email,
Phone: contact.Phone,
DigestAlgorithm: null,
SignatureAlgorithm: null,
SignerName: signature.SignerName,
Issuer: null,
SerialNumber: null,
ValidFrom: null,
ValidTo: null,
ExpiryCheckedAgainst: "now",
Certificates: [],
Unchanged: false,
FieldName: signature.FieldName,
IsCryptographicallyValid: false,
SigningTime: signature.SigningTime?.ToUniversalTime().ToString("O"),
Filter: signature.Filter,
SubFilter: signature.SubFilter,
ApplicationName: null,
UUID: reasonMeta.UUID,
OriginalDocumentSha256: reasonMeta.OriginalDocumentSha256,
MessageDigestHex: null,
Error: ex.Message));
}
}
return new PdfSignatureCheckResultDto(
Signed: true,
Signatures: items);
}
private static async Task<(Stream Stream, bool OwnsStream)> EnsureSeekableInputAsync(Stream input, CancellationToken cancellationToken)
{
if (input.CanSeek)
{
input.Position = 0;
return (input, false);
}
var buffered = new MemoryStream();
await input.CopyToAsync(buffered, cancellationToken);
buffered.Position = 0;
return (buffered, true);
}
private static async Task<byte[]> ReadAllBytesAsync(Stream input, CancellationToken cancellationToken)
{
if (input is MemoryStream memory && memory.TryGetBuffer(out var buffer))
return buffer.AsSpan(0, (int)memory.Length).ToArray();
if (input.CanSeek)
input.Position = 0;
using var ms = new MemoryStream();
await input.CopyToAsync(ms, cancellationToken);
return ms.ToArray();
}
private static PdfSignatureCertificateInfoDto BuildCertificateInfo(X509Certificate2? certificate, DateTimeOffset now)
{
if (certificate is null)
{
return new PdfSignatureCertificateInfoDto(
IssuedTo: new PdfSignaturePrincipalDto(null, null, null, null),
IssuedBy: new PdfSignaturePrincipalDto(null, null, null, null),
SerialNumber: null,
ValidFrom: null,
ValidTo: null,
SelfSigned: false,
Expired: false,
NotYetValid: false,
IsSigner: true,
Version: null,
KeyAlgorithm: null,
SignatureAlgorithm: null,
Pem: null,
KeySizeBits: null);
}
var issuedTo = ParsePrincipal(certificate.Subject);
var issuedBy = ParsePrincipal(certificate.Issuer);
var validFrom = new DateTimeOffset(certificate.NotBefore).ToUniversalTime();
var validTo = new DateTimeOffset(certificate.NotAfter).ToUniversalTime();
return new PdfSignatureCertificateInfoDto(
IssuedTo: issuedTo,
IssuedBy: issuedBy,
SerialNumber: ToColonSerial(certificate.SerialNumber),
ValidFrom: validFrom,
ValidTo: validTo,
SelfSigned: string.Equals(certificate.Subject, certificate.Issuer, StringComparison.OrdinalIgnoreCase),
Expired: validTo < now,
NotYetValid: validFrom > now,
IsSigner: true,
Version: certificate.Version,
KeyAlgorithm: certificate.PublicKey.Oid?.FriendlyName ?? certificate.PublicKey.Oid?.Value,
SignatureAlgorithm: certificate.SignatureAlgorithm.FriendlyName ?? certificate.SignatureAlgorithm.Value,
Pem: ToPem(certificate.RawData),
KeySizeBits: TryGetKeySize(certificate, out var keySize) ? keySize : null);
}
private static bool TryGetKeySize(X509Certificate2 certificate, out int keySize)
{
keySize = 0;
try
{
using var rsa = certificate.GetRSAPublicKey();
if (rsa is null)
return false;
keySize = rsa.KeySize;
return true;
}
catch (CryptographicException)
{
return false;
}
}
private static string? InferDigestAlgorithm(X509Certificate2? certificate)
{
if (certificate?.SignatureAlgorithm is null)
return null;
var source = (certificate.SignatureAlgorithm.FriendlyName ?? certificate.SignatureAlgorithm.Value ?? string.Empty).ToUpperInvariant();
if (source.Contains("SHA512", StringComparison.Ordinal)) return "SHA-512";
if (source.Contains("SHA384", StringComparison.Ordinal)) return "SHA-384";
if (source.Contains("SHA256", StringComparison.Ordinal)) return "SHA-256";
if (source.Contains("SHA1", StringComparison.Ordinal)) return "SHA-1";
return certificate.SignatureAlgorithm.FriendlyName ?? certificate.SignatureAlgorithm.Value;
}
private static string? InferSignatureAlgorithm(X509Certificate2? certificate)
{
var oidName = certificate?.PublicKey.Oid?.FriendlyName?.ToUpperInvariant();
if (oidName is not null && oidName.Contains("RSA", StringComparison.Ordinal))
return "RSA";
if (oidName is not null && oidName.Contains("ECDSA", StringComparison.Ordinal))
return "ECDSA";
return certificate?.PublicKey.Oid?.FriendlyName ?? certificate?.PublicKey.Oid?.Value;
}
private static PdfSignaturePrincipalDto ParsePrincipal(string? dn)
{
if (string.IsNullOrWhiteSpace(dn))
return new PdfSignaturePrincipalDto(null, null, null, dn);
string? commonName = null;
string? organization = null;
string? country = null;
foreach (var part in dn.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
var equalsIndex = part.IndexOf('=');
if (equalsIndex <= 0 || equalsIndex >= part.Length - 1)
continue;
var key = part[..equalsIndex].Trim();
var value = part[(equalsIndex + 1)..].Trim();
if (key.Equals("CN", StringComparison.OrdinalIgnoreCase)) commonName = value;
else if (key.Equals("O", StringComparison.OrdinalIgnoreCase)) organization = value;
else if (key.Equals("C", StringComparison.OrdinalIgnoreCase)) country = value;
}
return new PdfSignaturePrincipalDto(commonName, organization, country, dn);
}
private static string? ToColonSerial(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
return null;
var normalized = raw.Replace(":", string.Empty, StringComparison.Ordinal).Trim();
if (normalized.Length % 2 != 0)
return normalized;
return string.Join(':', Enumerable.Range(0, normalized.Length / 2).Select(i => normalized.Substring(i * 2, 2).ToUpperInvariant()));
}
private static string ToPem(byte[] der)
{
var base64 = Convert.ToBase64String(der, Base64FormattingOptions.InsertLineBreaks);
var sb = new StringBuilder();
sb.AppendLine("-----BEGIN CERTIFICATE-----");
sb.AppendLine(base64);
sb.Append("-----END CERTIFICATE-----");
return sb.ToString();
}
private static string? TryExtractMessageDigestHex(byte[]? signatureContents)
{
if (signatureContents is null || signatureContents.Length == 0)
return null;
try
{
var cms = new SignedCms();
cms.Decode(signatureContents);
var signerInfo = cms.SignerInfos.Count > 0 ? cms.SignerInfos[0] : null;
if (signerInfo is null)
return null;
foreach (CryptographicAttributeObject attr in signerInfo.SignedAttributes)
{
if (!string.Equals(attr.Oid?.Value, "1.2.840.113549.1.9.4", StringComparison.Ordinal))
continue;
foreach (var value in attr.Values)
{
var octets = TryReadOctetString(value.RawData);
if (octets is not null && octets.Length > 0)
return Convert.ToHexString(octets);
}
}
}
catch
{
return null;
}
return null;
}
private static byte[]? TryReadOctetString(byte[] rawData)
{
try
{
var reader = new AsnReader(rawData, AsnEncodingRules.BER);
var octets = reader.ReadOctetString();
if (reader.HasData)
return null;
return octets;
}
catch
{
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;
}
private static long GetSignedRevisionLength(long[] byteRange)
{
if (byteRange.Length < 2)
return -1;
var start = byteRange[^2];
var length = byteRange[^1];
if (start < 0 || length < 0)
return -1;
return start + length;
}
private static string BuildReasonWithMeta(string? businessReason, string uuid, string sourceHashHex)
{
var trimmedReason = string.IsNullOrWhiteSpace(businessReason) ? null : businessReason.Trim();
var prefix = string.IsNullOrWhiteSpace(trimmedReason) ? string.Empty : trimmedReason + " ";
return $"{prefix}| SFMETA:v1;doc={uuid};sha256={sourceHashHex}";
}
private static string? BuildContactInfo(string? email, string? phone)
{
var normalizedEmail = string.IsNullOrWhiteSpace(email) ? null : email.Trim();
var normalizedPhone = string.IsNullOrWhiteSpace(phone) ? null : phone.Trim();
if (normalizedEmail is null && normalizedPhone is null)
return null;
if (normalizedEmail is null)
return $"tel:{normalizedPhone}";
if (normalizedPhone is null)
return $"mailto:{normalizedEmail}";
return $"mailto:{normalizedEmail};tel:{normalizedPhone}";
}
private static (string? Email, string? Phone) ParseContactInfo(string? contactInfo)
{
if (string.IsNullOrWhiteSpace(contactInfo))
return (null, null);
string? email = null;
string? phone = null;
foreach (var part in contactInfo.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
if (part.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase))
{
email = part[7..].Trim();
continue;
}
if (part.StartsWith("tel:", StringComparison.OrdinalIgnoreCase))
{
phone = part[4..].Trim();
continue;
}
if (email is null && part.Contains('@', StringComparison.Ordinal))
{
email = part.Trim();
continue;
}
if (phone is null)
phone = part.Trim();
}
return (
string.IsNullOrWhiteSpace(email) ? null : email,
string.IsNullOrWhiteSpace(phone) ? null : phone);
}
private static (string? BusinessReason, string? UUID, string? OriginalDocumentSha256) ParseReasonMeta(string? reason)
{
if (string.IsNullOrWhiteSpace(reason))
return (reason, null, null);
var match = SignatureReasonMetaRegex.Match(reason);
if (!match.Success)
return (reason, null, null);
var businessReason = reason[..match.Index].Trim();
if (string.IsNullOrWhiteSpace(businessReason))
businessReason = null;
return (
businessReason,
match.Groups["doc"].Value,
match.Groups["sha"].Value.ToUpperInvariant());
}
}