Refactor to use streams for PDF handling

Refactored `IPdfDigitalSignatureService` to replace `byte[]` with `Stream` for input and output, improving memory efficiency and scalability. Updated `PdfDigitalSignatureService` implementation to support stream-based operations, including signing, checking, and extracting PDFs.

Modified `SignPdfAsync` to handle multiple signatures sequentially using streams and ensured proper disposal of intermediate streams. Updated `CheckPdfSignatureAsync` and `ExtractOriginalPdfAsync` to use streams, reducing memory overhead.

Refactored `PdfSignatureController` to use streams for file handling, enabling efficient processing of large files. Introduced helper methods for stream handling and enhanced cancellation support. Improved code maintainability by consolidating common logic and reducing duplication.
This commit is contained in:
2026-10-15 11:04:05 +02:00
parent 29108594d9
commit 04cea1ca4b
3 changed files with 147 additions and 86 deletions

View File

@@ -11,37 +11,37 @@ public interface IPdfDigitalSignatureService
/// Creates a digital signature for the provided PDF bytes and returns /// Creates a digital signature for the provided PDF bytes and returns
/// a new signed PDF document. /// a new signed PDF document.
/// </summary> /// </summary>
/// <param name="pdfBytes">Source PDF content as a byte array.</param> /// <param name="inputPdf">Source PDF content stream.</param>
/// <param name="outputPdf">Destination stream for signed PDF output.</param>
/// <param name="options">Optional signing options that map to PDF signature metadata fields.</param> /// <param name="options">Optional signing options that map to PDF signature metadata fields.</param>
/// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param> /// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param>
/// <returns>The signed PDF as a byte array.</returns> Task SignPdfAsync(Stream inputPdf, Stream outputPdf, PdfSignOptionsDto? options = null, CancellationToken cancellationToken = default);
Task<byte[]> SignPdfAsync(byte[] pdfBytes, PdfSignOptionsDto? options = null, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Applies multiple signatures to the provided PDF in sequence and returns /// Applies multiple signatures to the provided PDF in sequence and returns
/// the final signed PDF document. /// the final signed PDF document.
/// </summary> /// </summary>
/// <param name="pdfBytes">Source PDF content as a byte array.</param> /// <param name="inputPdf">Source PDF content stream.</param>
/// <param name="outputPdf">Destination stream for signed PDF output.</param>
/// <param name="optionsList">Ordered signing options; one signature is applied per item.</param> /// <param name="optionsList">Ordered signing options; one signature is applied per item.</param>
/// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param> /// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param>
/// <returns>The signed PDF as a byte array.</returns> Task SignPdfAsync(Stream inputPdf, Stream outputPdf, IReadOnlyList<PdfSignOptionsDto> optionsList, CancellationToken cancellationToken = default);
Task<byte[]> SignPdfAsync(byte[] pdfBytes, IReadOnlyList<PdfSignOptionsDto> optionsList, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Checks whether the provided PDF contains signature field(s) and whether /// Checks whether the provided PDF contains signature field(s) and whether
/// those signatures are cryptographically valid. /// those signatures are cryptographically valid.
/// </summary> /// </summary>
/// <param name="pdfBytes">PDF content to inspect.</param> /// <param name="inputPdf">PDF content stream to inspect.</param>
/// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param> /// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param>
/// <returns>A structured result containing signature presence and per-signature details.</returns> /// <returns>A structured result containing signature presence and per-signature details.</returns>
Task<PdfSignatureCheckResultDto> CheckPdfSignatureAsync(byte[] pdfBytes, CancellationToken cancellationToken = default); Task<PdfSignatureCheckResultDto> CheckPdfSignatureAsync(Stream inputPdf, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Extracts the original signed revision from a signed PDF. /// Extracts the original signed revision from a signed PDF.
/// For multi-revision files this returns the first signed revision bytes. /// For multi-revision files this returns the first signed revision bytes.
/// </summary> /// </summary>
/// <param name="pdfBytes">Signed PDF bytes.</param> /// <param name="inputPdf">Signed PDF content stream.</param>
/// <param name="outputPdf">Destination stream for original revision output.</param>
/// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param> /// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param>
/// <returns>PDF bytes of the original signed revision.</returns> Task ExtractOriginalPdfAsync(Stream inputPdf, Stream outputPdf, CancellationToken cancellationToken = default);
Task<byte[]> ExtractOriginalPdfAsync(byte[] pdfBytes, CancellationToken cancellationToken = default);
} }

View File

@@ -18,20 +18,29 @@ public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options)
{ {
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); 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) public async Task SignPdfAsync(Stream inputPdf, Stream outputPdf, PdfSignOptionsDto? optionsModel = null, CancellationToken cancellationToken = default)
{ {
ArgumentNullException.ThrowIfNull(pdfBytes); ArgumentNullException.ThrowIfNull(inputPdf);
ArgumentNullException.ThrowIfNull(outputPdf);
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 pkcs11Options = options.Value;
var request = optionsModel ?? new PdfSignOptionsDto(); var request = optionsModel ?? new PdfSignOptionsDto();
var uuid = ResolveUuid(request.UUID);
var reasonWithMeta = BuildReasonWithMeta(request.Reason ?? pkcs11Options.DefaultReason, uuid, sourceHashHex);
using var inputPdf = new MemoryStream(pdfBytes); using var signer = new PdfDocumentSigner(workingInput);
using var signer = new PdfDocumentSigner(inputPdf);
var digestCalculator = new DigestCalculator(HashAlgorithmType.SHA256); var digestCalculator = new DigestCalculator(HashAlgorithmType.SHA256);
var signerInfo = new ExternalSignerInfo(PdfSignatureType.Pkcs7, 32768, digestCalculator); 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) var signatureFieldInfo = new PdfSignatureFieldInfo(1)
{ {
Name = $"Sig_{DateTimeOffset.UtcNow:yyyyMMddHHmmss}" Name = $"Sig_{DateTimeOffset.UtcNow:yyyyMMddHHmmss}"
@@ -52,45 +61,78 @@ public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options)
var deferredSigner = signer.SignDeferred(builder); var deferredSigner = signer.SignDeferred(builder);
var externalSigner = new Pkcs11ExternalSigner(pkcs11Options); var externalSigner = new Pkcs11ExternalSigner(pkcs11Options);
using var outputPdf = new MemoryStream();
deferredSigner.Sign(outputPdf, externalSigner.BuildSignature(deferredSigner.HashValue, digestCalculator.AlgorithmOid)); deferredSigner.Sign(outputPdf, externalSigner.BuildSignature(deferredSigner.HashValue, digestCalculator.AlgorithmOid));
return Task.FromResult(outputPdf.ToArray()); }
finally
{
if (ownsWorkingInput)
await workingInput.DisposeAsync();
}
} }
public async Task<byte[]> SignPdfAsync(byte[] pdfBytes, IReadOnlyList<PdfSignOptionsDto> optionsList, CancellationToken cancellationToken = default) public async Task SignPdfAsync(Stream inputPdf, Stream outputPdf, IReadOnlyList<PdfSignOptionsDto> optionsList, CancellationToken cancellationToken = default)
{ {
ArgumentNullException.ThrowIfNull(pdfBytes); ArgumentNullException.ThrowIfNull(inputPdf);
ArgumentNullException.ThrowIfNull(outputPdf);
ArgumentNullException.ThrowIfNull(optionsList); ArgumentNullException.ThrowIfNull(optionsList);
cancellationToken.ThrowIfCancellationRequested();
if (optionsList.Count == 0) if (optionsList.Count == 0)
return pdfBytes; {
await inputPdf.CopyToAsync(outputPdf, cancellationToken);
return;
}
var currentPdf = pdfBytes; var (currentInput, ownsCurrentInput) = await EnsureSeekableInputAsync(inputPdf, cancellationToken);
foreach (var signOptions in optionsList) try
{
for (var i = 0; i < optionsList.Count; i++)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
currentPdf = await SignPdfAsync(currentPdf, signOptions, cancellationToken); var isLast = i == optionsList.Count - 1;
}
return currentPdf; if (isLast)
}
public Task<PdfSignatureCheckResultDto> CheckPdfSignatureAsync(byte[] pdfBytes, CancellationToken cancellationToken = default)
{ {
ArgumentNullException.ThrowIfNull(pdfBytes); await SignPdfAsync(currentInput, outputPdf, optionsList[i], cancellationToken);
continue;
}
using var inputPdf = new MemoryStream(pdfBytes); var intermediateOutput = new MemoryStream();
using var signer = new PdfDocumentSigner(inputPdf); 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 async Task<PdfSignatureCheckResultDto> CheckPdfSignatureAsync(Stream inputPdf, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(inputPdf);
cancellationToken.ThrowIfCancellationRequested();
var pdfBytes = await ReadAllBytesAsync(inputPdf, cancellationToken);
using var pdfStream = new MemoryStream(pdfBytes);
using var signer = new PdfDocumentSigner(pdfStream);
var signatures = signer.GetSignatureInfo(); var signatures = signer.GetSignatureInfo();
var byteRanges = ParseByteRanges(pdfBytes); var byteRanges = ParseByteRanges(pdfBytes);
if (signatures is null || signatures.Count == 0) if (signatures is null || signatures.Count == 0)
{ {
return Task.FromResult(new PdfSignatureCheckResultDto( return new PdfSignatureCheckResultDto(
Signed: false, Signed: false,
Signatures: [])); Signatures: []);
} }
var items = new List<PdfSignatureCheckItemDto>(signatures.Count); var items = new List<PdfSignatureCheckItemDto>(signatures.Count);
@@ -177,19 +219,25 @@ public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options)
} }
} }
return Task.FromResult(new PdfSignatureCheckResultDto( return new PdfSignatureCheckResultDto(
Signed: true, Signed: true,
Signatures: items)); Signatures: items);
} }
public Task<byte[]> 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(); cancellationToken.ThrowIfCancellationRequested();
var pdfBytes = await ReadAllBytesAsync(inputPdf, cancellationToken);
var byteRanges = ParseByteRanges(pdfBytes); var byteRanges = ParseByteRanges(pdfBytes);
if (byteRanges.Count == 0) if (byteRanges.Count == 0)
return Task.FromResult(pdfBytes); {
await outputPdf.WriteAsync(pdfBytes, cancellationToken);
return;
}
var revisionLength = byteRanges var revisionLength = byteRanges
.Select(GetSignedRevisionLength) .Select(GetSignedRevisionLength)
@@ -200,9 +248,34 @@ public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options)
if (revisionLength > pdfBytes.LongLength) if (revisionLength > pdfBytes.LongLength)
throw new InvalidOperationException("Invalid ByteRange in signed PDF."); throw new InvalidOperationException("Invalid ByteRange in signed PDF.");
var originalPdf = new byte[revisionLength]; await outputPdf.WriteAsync(pdfBytes.AsMemory(0, (int)revisionLength), cancellationToken);
Buffer.BlockCopy(pdfBytes, 0, originalPdf, 0, (int)revisionLength); }
return Task.FromResult(originalPdf);
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) private static PdfSignatureCertificateInfoDto BuildCertificateInfo(X509Certificate2? certificate, DateTimeOffset now)

View File

@@ -39,14 +39,6 @@ public class PdfSignatureController(IPdfDigitalSignatureService signatureService
if (!file.FileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)) if (!file.FileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
return BadRequest("Only PDF files are supported."); 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 var signOptions = new PdfSignOptionsDto
{ {
UUID = request.UUID, UUID = request.UUID,
@@ -58,9 +50,12 @@ public class PdfSignatureController(IPdfDigitalSignatureService signatureService
CertificationLevel = PdfSignatureCertificationLevel.FormFillingAndAnnotations 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); var fileName = Path.GetFileNameWithoutExtension(file.FileName);
return File(signedPdf, "application/pdf", $"{fileName}-signed.pdf"); return File(output, "application/pdf", $"{fileName}-signed.pdf");
} }
[AllowAnonymous] [AllowAnonymous]
@@ -76,15 +71,8 @@ public class PdfSignatureController(IPdfDigitalSignatureService signatureService
if (!file.FileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)) if (!file.FileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
return BadRequest("Only PDF files are supported."); return BadRequest("Only PDF files are supported.");
byte[] sourcePdf; await using var input = file.OpenReadStream();
await using (var input = file.OpenReadStream()) var result = await signatureService.CheckPdfSignatureAsync(input, cancel);
using (var ms = new MemoryStream())
{
await input.CopyToAsync(ms, cancel);
sourcePdf = ms.ToArray();
}
var result = await signatureService.CheckPdfSignatureAsync(sourcePdf, cancel);
return Ok(result); return Ok(result);
} }
} }