The `SigningTime` property has been removed from the `PdfSignOptionsDto` class and related client-side input handling. It is now set automatically on the server side based on the `UseUtcSigningTime` configuration (`UtcNow` or local time). Updated the `PdfDigitalSignatureService` to always set `SigningTime` server-side, ensuring consistent and deterministic behavior. Removed `SigningTime` from the `PdfSignatureController` and adjusted the logic to exclude it from the `signOptions` object. Updated the `README.md` to reflect these changes, clarifying that `SigningTime` is now server-side only. These changes improve security and simplify client integration.
485 lines
19 KiB
C#
485 lines
19 KiB
C#
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<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 Task<byte[]> 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 = 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<byte[]> SignPdfAsync(byte[] pdfBytes, IReadOnlyList<PdfSignOptionsDto> 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<PdfSignatureCheckResultDto> 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<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);
|
|
|
|
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<byte[]> 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<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 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());
|
|
}
|
|
}
|