diff --git a/EnvelopeGenerator.Application/Common/Interfaces/Services/IPdfDigitalSignatureService.cs b/EnvelopeGenerator.Application/Common/Interfaces/Services/IPdfDigitalSignatureService.cs index 4bfdd640..cd875279 100644 --- a/EnvelopeGenerator.Application/Common/Interfaces/Services/IPdfDigitalSignatureService.cs +++ b/EnvelopeGenerator.Application/Common/Interfaces/Services/IPdfDigitalSignatureService.cs @@ -11,37 +11,37 @@ public interface IPdfDigitalSignatureService /// Creates a digital signature for the provided PDF bytes and returns /// a new signed PDF document. /// - /// Source PDF content as a byte array. + /// Source PDF content stream. + /// Destination stream for signed PDF output. /// Optional signing options that map to PDF signature metadata fields. /// Cancellation token for cooperative cancellation. - /// The signed PDF as a byte array. - Task SignPdfAsync(byte[] pdfBytes, PdfSignOptionsDto? options = null, CancellationToken cancellationToken = default); + 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 as a byte array. + /// Source PDF content stream. + /// Destination stream for signed PDF output. /// Ordered signing options; one signature is applied per item. /// Cancellation token for cooperative cancellation. - /// The signed PDF as a byte array. - Task SignPdfAsync(byte[] pdfBytes, IReadOnlyList optionsList, CancellationToken cancellationToken = default); + Task SignPdfAsync(Stream inputPdf, Stream outputPdf, IReadOnlyList optionsList, CancellationToken cancellationToken = default); /// /// Checks whether the provided PDF contains signature field(s) and whether /// those signatures are cryptographically valid. /// - /// PDF content to inspect. + /// PDF content stream to inspect. /// Cancellation token for cooperative cancellation. /// A structured result containing signature presence and per-signature details. - Task CheckPdfSignatureAsync(byte[] pdfBytes, CancellationToken cancellationToken = default); + 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 bytes. + /// Signed PDF content stream. + /// Destination stream for original revision output. /// Cancellation token for cooperative cancellation. - /// PDF bytes of the original signed revision. - Task ExtractOriginalPdfAsync(byte[] pdfBytes, CancellationToken cancellationToken = default); + Task ExtractOriginalPdfAsync(Stream inputPdf, Stream outputPdf, CancellationToken cancellationToken = default); } diff --git a/EnvelopeGenerator.Infrastructure.Crypt/Services/PdfDigitalSignatureService.cs b/EnvelopeGenerator.Infrastructure.Crypt/Services/PdfDigitalSignatureService.cs index 2c68ddc9..c469359d 100644 --- a/EnvelopeGenerator.Infrastructure.Crypt/Services/PdfDigitalSignatureService.cs +++ b/EnvelopeGenerator.Infrastructure.Crypt/Services/PdfDigitalSignatureService.cs @@ -18,79 +18,121 @@ 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 Task SignPdfAsync(byte[] pdfBytes, PdfSignOptionsDto? optionsModel = null, CancellationToken cancellationToken = default) + public async Task SignPdfAsync(Stream inputPdf, Stream outputPdf, PdfSignOptionsDto? optionsModel = null, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(pdfBytes); - var pkcs11Options = options.Value; - var request = optionsModel ?? new PdfSignOptionsDto(); + ArgumentNullException.ThrowIfNull(inputPdf); + ArgumentNullException.ThrowIfNull(outputPdf); + cancellationToken.ThrowIfCancellationRequested(); - using var inputPdf = new MemoryStream(pdfBytes); - using var signer = new PdfDocumentSigner(inputPdf); + var (workingInput, ownsWorkingInput) = await EnsureSeekableInputAsync(inputPdf, cancellationToken); - 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) + try { - Name = $"Sig_{DateTimeOffset.UtcNow:yyyyMMddHHmmss}" - }; + workingInput.Position = 0; + var sourceHashHex = Convert.ToHexString(await SHA256.HashDataAsync(workingInput, cancellationToken)); + workingInput.Position = 0; - var builder = new PdfDeferredSignatureBuilder(signerInfo, signatureFieldInfo) + var pkcs11Options = options.Value; + var request = optionsModel ?? new PdfSignOptionsDto(); + var uuid = ResolveUuid(request.UUID); + var reasonWithMeta = BuildReasonWithMeta(request.Reason ?? pkcs11Options.DefaultReason, uuid, sourceHashHex); + + using var signer = new PdfDocumentSigner(workingInput); + + var digestCalculator = new DigestCalculator(HashAlgorithmType.SHA256); + var signerInfo = new ExternalSignerInfo(PdfSignatureType.Pkcs7, 32768, digestCalculator); + 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 = BuildContactInfo(request.Email, request.Phone), + 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); + deferredSigner.Sign(outputPdf, externalSigner.BuildSignature(deferredSigner.HashValue, digestCalculator.AlgorithmOid)); + } + finally { - 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 - }; - - 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()); + if (ownsWorkingInput) + await workingInput.DisposeAsync(); + } } - public async Task SignPdfAsync(byte[] pdfBytes, IReadOnlyList optionsList, CancellationToken cancellationToken = default) + public async Task SignPdfAsync(Stream inputPdf, Stream outputPdf, IReadOnlyList optionsList, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(pdfBytes); + ArgumentNullException.ThrowIfNull(inputPdf); + ArgumentNullException.ThrowIfNull(outputPdf); ArgumentNullException.ThrowIfNull(optionsList); + cancellationToken.ThrowIfCancellationRequested(); if (optionsList.Count == 0) - return pdfBytes; - - var currentPdf = pdfBytes; - - foreach (var signOptions in optionsList) { - cancellationToken.ThrowIfCancellationRequested(); - currentPdf = await SignPdfAsync(currentPdf, signOptions, cancellationToken); + await inputPdf.CopyToAsync(outputPdf, cancellationToken); + return; } - return currentPdf; + 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(); + } } - public Task CheckPdfSignatureAsync(byte[] pdfBytes, CancellationToken cancellationToken = default) + public async Task CheckPdfSignatureAsync(Stream inputPdf, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(pdfBytes); + ArgumentNullException.ThrowIfNull(inputPdf); + cancellationToken.ThrowIfCancellationRequested(); - using var inputPdf = new MemoryStream(pdfBytes); - using var signer = new PdfDocumentSigner(inputPdf); + var pdfBytes = await ReadAllBytesAsync(inputPdf, cancellationToken); + + using var pdfStream = new MemoryStream(pdfBytes); + using var signer = new PdfDocumentSigner(pdfStream); var signatures = signer.GetSignatureInfo(); var byteRanges = ParseByteRanges(pdfBytes); if (signatures is null || signatures.Count == 0) { - return Task.FromResult(new PdfSignatureCheckResultDto( + return new PdfSignatureCheckResultDto( Signed: false, - Signatures: [])); + Signatures: []); } var items = new List(signatures.Count); @@ -177,19 +219,25 @@ public sealed class PdfDigitalSignatureService(IOptions options) } } - return Task.FromResult(new PdfSignatureCheckResultDto( + return new PdfSignatureCheckResultDto( Signed: true, - Signatures: items)); + Signatures: items); } - public Task ExtractOriginalPdfAsync(byte[] pdfBytes, CancellationToken cancellationToken = default) + public async Task ExtractOriginalPdfAsync(Stream inputPdf, Stream outputPdf, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(pdfBytes); + ArgumentNullException.ThrowIfNull(inputPdf); + ArgumentNullException.ThrowIfNull(outputPdf); cancellationToken.ThrowIfCancellationRequested(); + var pdfBytes = await ReadAllBytesAsync(inputPdf, cancellationToken); + var byteRanges = ParseByteRanges(pdfBytes); if (byteRanges.Count == 0) - return Task.FromResult(pdfBytes); + { + await outputPdf.WriteAsync(pdfBytes, cancellationToken); + return; + } var revisionLength = byteRanges .Select(GetSignedRevisionLength) @@ -200,9 +248,34 @@ public sealed class PdfDigitalSignatureService(IOptions options) 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); + 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) + { + 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 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) diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/PdfSignatureController.cs b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/PdfSignatureController.cs index 1f0500b3..cec1609e 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/PdfSignatureController.cs +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/PdfSignatureController.cs @@ -39,14 +39,6 @@ public class PdfSignatureController(IPdfDigitalSignatureService signatureService if (!file.FileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)) return BadRequest("Only PDF files are supported."); - byte[] sourcePdf; - await using (var input = file.OpenReadStream()) - using (var ms = new MemoryStream()) - { - await input.CopyToAsync(ms, cancel); - sourcePdf = ms.ToArray(); - } - var signOptions = new PdfSignOptionsDto { UUID = request.UUID, @@ -58,9 +50,12 @@ public class PdfSignatureController(IPdfDigitalSignatureService signatureService CertificationLevel = PdfSignatureCertificationLevel.FormFillingAndAnnotations }; - byte[] signedPdf = await signatureService.SignPdfAsync(sourcePdf, signOptions, cancel); + await using var input = file.OpenReadStream(); + await using var output = new MemoryStream(); + await signatureService.SignPdfAsync(input, output, signOptions, cancel); + output.Position = 0; var fileName = Path.GetFileNameWithoutExtension(file.FileName); - return File(signedPdf, "application/pdf", $"{fileName}-signed.pdf"); + return File(output, "application/pdf", $"{fileName}-signed.pdf"); } [AllowAnonymous] @@ -76,15 +71,8 @@ public class PdfSignatureController(IPdfDigitalSignatureService signatureService if (!file.FileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)) return BadRequest("Only PDF files are supported."); - byte[] sourcePdf; - await using (var input = file.OpenReadStream()) - using (var ms = new MemoryStream()) - { - await input.CopyToAsync(ms, cancel); - sourcePdf = ms.ToArray(); - } - - var result = await signatureService.CheckPdfSignatureAsync(sourcePdf, cancel); + await using var input = file.OpenReadStream(); + var result = await signatureService.CheckPdfSignatureAsync(input, cancel); return Ok(result); } }