Files
DocumentService/DocumentService.Infrastructure.Crypt/Services/PdfDigitalSignatureService.cs
TekH 5e7652ef88 Refactor and rename to DocumentService
Renamed namespaces and projects from DocumentOperator to DocumentService, updating all references accordingly. Refactored PdfSignatureAppearanceDto and related enums to use properties with init accessors for improved clarity. Removed and re-added IPkcs11SelfTestStatusProvider with updated namespace. Simplified dependency injection by removing DocumentOperatorApplication registration. Updated appsettings.json with a specific AssetsRootPath. Incremented version numbers in project files for a new release. Introduced PdfSignatureClient and IPdfSignatureClient for PDF digital-signing operations. Updated project files to use newer Microsoft.Extensions package versions. Removed all DocumentOperator references, completing the transition to DocumentService. Added new DTOs and enums for PDF signature options and appearance.
2026-10-25 18:49:08 +01:00

632 lines
24 KiB
C#

using DevExpress.Office.DigitalSignatures;
using DevExpress.Pdf;
using DocumentService.Domain.Common.Exceptions;
using DocumentService.Application.Common.Configuration;
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.Common.Interfaces;
using DocumentService.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 DocumentService.Infrastructure.Crypt.Services;
public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options) : IPdfDigitalSignatureService
{
private static readonly Regex SignatureReasonMetaRegex = new(@"\|\s*SFMETA:v1;deid=(?<deid>\d+);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.DocumentElementId, 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:yyyyMMddHHmmssfff}",
SignatureBounds = BuildSignatureBounds(request.Appearance, workingInput)
});
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();
}
}
private static PdfRectangle BuildSignatureBounds(PdfSignatureAppearanceDto appearance, Stream inputPdf)
{
var left = appearance.Left;
var bottom = appearance.Bottom;
var width = appearance.Width;
var height = appearance.Height;
if (appearance.CoordinateUnit == PdfSignatureCoordinateUnit.Inch)
{
left *= 72.0;
bottom *= 72.0;
width *= 72.0;
height *= 72.0;
}
if (width <= 0 || height <= 0)
throw new BadRequestException("Signature appearance width and height must be greater than zero.");
if (appearance.CoordinateOrigin == PdfSignatureCoordinateOrigin.TopLeft)
{
var pageHeight = GetPageHeightInPoints(inputPdf, appearance.PageNumber);
bottom = pageHeight - bottom - height;
}
return new PdfRectangle(left, bottom, left + width, bottom + height);
}
private static double GetPageHeightInPoints(Stream inputPdf, int pageNumber)
{
if (pageNumber <= 0)
throw new BadRequestException("Signature page number must be greater than zero.");
var previousPosition = inputPdf.CanSeek ? inputPdf.Position : 0;
try
{
if (inputPdf.CanSeek)
inputPdf.Position = 0;
using var processor = new PdfDocumentProcessor();
processor.LoadDocument(inputPdf);
var pageCount = processor.Document.Pages.Count;
if (pageNumber > pageCount)
throw new BadRequestException($"Signature page number {pageNumber} is out of range. Document has {pageCount} page(s).");
return processor.Document.Pages[pageNumber - 1].CropBox.Height;
}
finally
{
if (inputPdf.CanSeek)
inputPdf.Position = previousPosition;
}
}
//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,
DocumentElementId: reasonMeta.DocumentElementId,
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,
DocumentElementId: reasonMeta.DocumentElementId,
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, long documentElementId, string sourceHashHex)
{
var trimmedReason = string.IsNullOrWhiteSpace(businessReason) ? null : businessReason.Trim();
var prefix = string.IsNullOrWhiteSpace(trimmedReason) ? string.Empty : trimmedReason + " ";
return $"{prefix}| SFMETA:v1;deid={documentElementId};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, long? DocumentElementId, 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;
long? parsedDocumentElementId = long.TryParse(match.Groups["deid"].Value, out var deid) ? deid : null;
return (
businessReason,
parsedDocumentElementId,
match.Groups["sha"].Value.ToUpperInvariant());
}
}