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 Microsoft.Extensions.Options; using System.Formats.Asn1; using System.Security.Cryptography.Pkcs; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.RegularExpressions; namespace EnvelopeGenerator.Infrastructure.Crypt.Services; public sealed class PdfDigitalSignatureService(IOptions options) : IPdfDigitalSignatureService { 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 Task SignPdfAsync(byte[] pdfBytes, PdfSignOptionsDto? optionsModel = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(pdfBytes); var pkcs11Options = options.Value; var request = optionsModel ?? new PdfSignOptionsDto(); using var inputPdf = new MemoryStream(pdfBytes); using var signer = new PdfDocumentSigner(inputPdf); var digestCalculator = new DigestCalculator(HashAlgorithmType.SHA256); var signerInfo = new ExternalSignerInfo(PdfSignatureType.Pkcs7, 32768, digestCalculator); var sourceHashHex = Convert.ToHexString(SHA256.HashData(pdfBytes)); var uuid = ResolveUuid(request.UUID); var reasonWithMeta = BuildReasonWithMeta(request.Reason ?? pkcs11Options.DefaultReason, uuid, sourceHashHex); var signatureFieldInfo = new PdfSignatureFieldInfo(1) { Name = $"Sig_{DateTimeOffset.UtcNow:yyyyMMddHHmmss}" }; var builder = new PdfDeferredSignatureBuilder(signerInfo, signatureFieldInfo) { Name = request.Name ?? pkcs11Options.CertificateLabel, Reason = reasonWithMeta, Location = request.Location, ContactInfo = request.ContactInfo, SigningTime = request.SigningTime ?? (pkcs11Options.UseUtcSigningTime ? DateTimeOffset.UtcNow : DateTimeOffset.Now), ApplicationName = pkcs11Options.ApplicationName, CertificationLevel = ToDevExpressCertificationLevel(request.CertificationLevel) ?? ToDevExpressCertificationLevel(pkcs11Options.DefaultCertificationLevel) ?? PdfCertificationLevel.NoCertification }; var deferredSigner = signer.SignDeferred(builder); var externalSigner = new Pkcs11ExternalSigner(pkcs11Options); using var outputPdf = new MemoryStream(); deferredSigner.Sign(outputPdf, externalSigner.BuildSignature(deferredSigner.HashValue, digestCalculator.AlgorithmOid)); return Task.FromResult(outputPdf.ToArray()); } public async Task SignPdfAsync(byte[] pdfBytes, IReadOnlyList optionsList, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(pdfBytes); ArgumentNullException.ThrowIfNull(optionsList); if (optionsList.Count == 0) return pdfBytes; var currentPdf = pdfBytes; foreach (var signOptions in optionsList) { cancellationToken.ThrowIfCancellationRequested(); currentPdf = await SignPdfAsync(currentPdf, signOptions, cancellationToken); } return currentPdf; } public Task CheckPdfSignatureAsync(byte[] pdfBytes, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(pdfBytes); using var inputPdf = 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 Task.FromResult(new PdfSignatureCheckResultDto( Signed: false, Signatures: [])); } var items = new List(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); items.Add(new PdfSignatureCheckItemDto( CoversWholeDoc: coversWholeDocument, Reason: signature.Reason, BusinessReason: reasonMeta.BusinessReason, Location: signature.Location, DeclaredName: signature.SignerName, ContactInfo: signature.ContactInfo, 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); items.Add(new PdfSignatureCheckItemDto( CoversWholeDoc: false, Reason: signature.Reason, BusinessReason: reasonMeta.BusinessReason, Location: signature.Location, DeclaredName: signature.SignerName, ContactInfo: signature.ContactInfo, 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 Task.FromResult(new PdfSignatureCheckResultDto( Signed: true, Signatures: items)); } public Task ExtractOriginalPdfAsync(byte[] pdfBytes, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(pdfBytes); cancellationToken.ThrowIfCancellationRequested(); var byteRanges = ParseByteRanges(pdfBytes); if (byteRanges.Count == 0) return Task.FromResult(pdfBytes); 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."); var originalPdf = new byte[revisionLength]; Buffer.BlockCopy(pdfBytes, 0, originalPdf, 0, (int)revisionLength); return Task.FromResult(originalPdf); } 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)); } private static int? TryGetKeySize(X509Certificate2 certificate) { try { return certificate.PublicKey.Key.KeySize; } catch { return null; } } 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 PdfCertificationLevel? ToDevExpressCertificationLevel(PdfSignatureCertificationLevel? value) { return value is null ? null : (PdfCertificationLevel)(int)value.Value; } 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 ParseByteRanges(byte[] pdfBytes) { var text = Encoding.Latin1.GetString(pdfBytes); var matches = Regex.Matches(text, @"/ByteRange\s*\[(?[\d\s]+)\]", RegexOptions.CultureInvariant); var ranges = new List(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 ResolveUuid(string? uuid) { return Guid.TryParse(uuid, out var parsed) ? parsed.ToString() : Guid.NewGuid().ToString(); } 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? 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()); } }