perf(pdf): use random access read and streaming for large pdf signing

This commit is contained in:
Claudecio Martins
2026-10-20 16:01:28 -03:00
parent 051614a919
commit e3d56d90af
2 changed files with 226 additions and 20 deletions
@@ -10,6 +10,9 @@ import br.com.cybercore.signer.core.protocol.CcspErrorCode;
import br.com.cybercore.signer.core.provider.CertificateManager; import br.com.cybercore.signer.core.provider.CertificateManager;
import br.com.cybercore.signer.core.provider.CertificateProviderException; import br.com.cybercore.signer.core.provider.CertificateProviderException;
import org.apache.pdfbox.Loader; import org.apache.pdfbox.Loader;
import org.apache.pdfbox.io.RandomAccessRead;
import org.apache.pdfbox.io.RandomAccessReadBuffer;
import org.apache.pdfbox.io.RandomAccessReadBufferedFile;
import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream; import org.apache.pdfbox.pdmodel.PDPageContentStream;
@@ -83,7 +86,19 @@ public class PdfSigningService {
*/ */
public static int getPageCount(byte[] pdfBytes) throws IOException { public static int getPageCount(byte[] pdfBytes) throws IOException {
Objects.requireNonNull(pdfBytes, "Bytes do PDF não podem ser nulos."); Objects.requireNonNull(pdfBytes, "Bytes do PDF não podem ser nulos.");
try (PDDocument doc = Loader.loadPDF(pdfBytes)) { try (RandomAccessReadBuffer rar = new RandomAccessReadBuffer(pdfBytes);
PDDocument doc = Loader.loadPDF(rar)) {
return doc.getNumberOfPages();
}
}
/**
* Retorna a quantidade total de páginas contidas no arquivo PDF.
*/
public static int getPageCount(File pdfFile) throws IOException {
Objects.requireNonNull(pdfFile, "Arquivo PDF não pode ser nulo.");
try (RandomAccessReadBufferedFile rar = new RandomAccessReadBufferedFile(pdfFile);
PDDocument doc = Loader.loadPDF(rar)) {
return doc.getNumberOfPages(); return doc.getNumberOfPages();
} }
} }
@@ -93,7 +108,24 @@ public class PdfSigningService {
*/ */
public static PageDimensions getPageDimensions(byte[] pdfBytes, int pageIndex0Based) throws IOException { public static PageDimensions getPageDimensions(byte[] pdfBytes, int pageIndex0Based) throws IOException {
Objects.requireNonNull(pdfBytes, "Bytes do PDF não podem ser nulos."); Objects.requireNonNull(pdfBytes, "Bytes do PDF não podem ser nulos.");
try (PDDocument doc = Loader.loadPDF(pdfBytes)) { try (RandomAccessReadBuffer rar = new RandomAccessReadBuffer(pdfBytes);
PDDocument doc = Loader.loadPDF(rar)) {
if (pageIndex0Based < 0 || pageIndex0Based >= doc.getNumberOfPages()) {
throw new IllegalArgumentException("Índice de página inválido: " + pageIndex0Based + ". Total: " + doc.getNumberOfPages());
}
PDPage page = doc.getPage(pageIndex0Based);
PDRectangle mediaBox = page.getMediaBox();
return new PageDimensions(mediaBox.getWidth(), mediaBox.getHeight());
}
}
/**
* Retorna as dimensões em pontos (pt) da página informada do arquivo PDF.
*/
public static PageDimensions getPageDimensions(File pdfFile, int pageIndex0Based) throws IOException {
Objects.requireNonNull(pdfFile, "Arquivo PDF não pode ser nulo.");
try (RandomAccessReadBufferedFile rar = new RandomAccessReadBufferedFile(pdfFile);
PDDocument doc = Loader.loadPDF(rar)) {
if (pageIndex0Based < 0 || pageIndex0Based >= doc.getNumberOfPages()) { if (pageIndex0Based < 0 || pageIndex0Based >= doc.getNumberOfPages()) {
throw new IllegalArgumentException("Índice de página inválido: " + pageIndex0Based + ". Total: " + doc.getNumberOfPages()); throw new IllegalArgumentException("Índice de página inválido: " + pageIndex0Based + ". Total: " + doc.getNumberOfPages());
} }
@@ -108,7 +140,8 @@ public class PdfSigningService {
*/ */
public static BufferedImage renderPageImage(byte[] pdfBytes, int pageIndex0Based, float dpi) throws IOException { public static BufferedImage renderPageImage(byte[] pdfBytes, int pageIndex0Based, float dpi) throws IOException {
Objects.requireNonNull(pdfBytes, "Bytes do PDF não podem ser nulos."); Objects.requireNonNull(pdfBytes, "Bytes do PDF não podem ser nulos.");
try (PDDocument doc = Loader.loadPDF(pdfBytes)) { try (RandomAccessReadBuffer rar = new RandomAccessReadBuffer(pdfBytes);
PDDocument doc = Loader.loadPDF(rar)) {
if (pageIndex0Based < 0 || pageIndex0Based >= doc.getNumberOfPages()) { if (pageIndex0Based < 0 || pageIndex0Based >= doc.getNumberOfPages()) {
throw new IllegalArgumentException("Índice de página inválido: " + pageIndex0Based + ". Total: " + doc.getNumberOfPages()); throw new IllegalArgumentException("Índice de página inválido: " + pageIndex0Based + ". Total: " + doc.getNumberOfPages());
} }
@@ -128,23 +161,55 @@ public class PdfSigningService {
} }
/** /**
* Assina digitalmente um arquivo PDF do sistema de arquivos e grava o resultado no arquivo de destino. * Assina digitalmente um arquivo PDF do sistema de arquivos e grava o resultado no arquivo de destino,
* utilizando streaming direto via RandomAccessReadBufferedFile para minimizar o consumo de memória heap.
*/ */
public void signPdfFile(File sourcePdf, File targetPdf, PdfSignatureOptions options) throws Exception { public void signPdfFile(File sourcePdf, File targetPdf, PdfSignatureOptions options) throws Exception {
Objects.requireNonNull(sourcePdf, "Arquivo PDF de origem não pode ser nulo."); Objects.requireNonNull(sourcePdf, "Arquivo PDF de origem não pode ser nulo.");
Objects.requireNonNull(targetPdf, "Arquivo PDF de destino não pode ser nulo."); Objects.requireNonNull(targetPdf, "Arquivo PDF de destino não pode ser nulo.");
Objects.requireNonNull(options, "Opções de assinatura não podem ser nulas."); Objects.requireNonNull(options, "Opções de assinatura não podem ser nulas.");
byte[] inputBytes = Files.readAllBytes(sourcePdf.toPath()); if (!sourcePdf.exists() || !sourcePdf.isFile()) {
byte[] signedBytes = signPdf(inputBytes, options); throw new IllegalArgumentException("Arquivo PDF de origem não existe: " + sourcePdf.getAbsolutePath());
}
if (targetPdf.getParentFile() != null) { boolean sameFile = sourcePdf.getCanonicalPath().equals(targetPdf.getCanonicalPath());
targetPdf.getParentFile().mkdirs(); File parentDir = targetPdf.getParentFile();
if (parentDir != null && !parentDir.exists()) {
parentDir.mkdirs();
} }
try (FileOutputStream fos = new FileOutputStream(targetPdf)) {
fos.write(signedBytes); File effectiveTarget = sameFile
? Files.createTempFile("cybercore_signed_", ".pdf").toFile()
: targetPdf;
try {
// Calcula o hash SHA-256 via streaming em blocos de 64KB sem carregar o arquivo na RAM
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
try (InputStream is = Files.newInputStream(sourcePdf.toPath())) {
byte[] buffer = new byte[65536];
int read;
while ((read = is.read(buffer)) != -1) {
sha256.update(buffer, 0, read);
}
}
byte[] pdfDigest = sha256.digest();
try (RandomAccessReadBufferedFile rar = new RandomAccessReadBufferedFile(sourcePdf);
FileOutputStream fos = new FileOutputStream(effectiveTarget)) {
signPdfInternal(rar, fos, options, pdfDigest);
}
if (sameFile) {
Files.move(effectiveTarget.toPath(), targetPdf.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING);
}
} finally {
if (sameFile && effectiveTarget.exists()) {
Files.deleteIfExists(effectiveTarget.toPath());
}
} }
logger.info("PDF assinado com sucesso e salvo em: {}", targetPdf.getAbsolutePath());
logger.info("PDF assinado com sucesso e salvo em: {}", targetPdf.getAbsolutePath().replaceAll("[\r\n]", ""));
} }
/** /**
@@ -155,6 +220,39 @@ public class PdfSigningService {
Objects.requireNonNull(pdfBytes, "Bytes do PDF não podem ser nulos."); Objects.requireNonNull(pdfBytes, "Bytes do PDF não podem ser nulos.");
Objects.requireNonNull(options, "Opções de assinatura não podem ser nulas."); Objects.requireNonNull(options, "Opções de assinatura não podem ser nulas.");
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
byte[] pdfDigest = sha256.digest(pdfBytes);
// Se o PDF for muito grande (> 2MB), usamos um arquivo temporário com RandomAccessReadBufferedFile
// para aliviar significativamente a pressão no Garbage Collector e economizar memória heap.
if (pdfBytes.length > 2 * 1024 * 1024) {
File tempSource = Files.createTempFile("cybercore_in_", ".pdf").toFile();
File tempTarget = Files.createTempFile("cybercore_out_", ".pdf").toFile();
try {
Files.write(tempSource.toPath(), pdfBytes);
try (RandomAccessReadBufferedFile rar = new RandomAccessReadBufferedFile(tempSource);
FileOutputStream fos = new FileOutputStream(tempTarget)) {
signPdfInternal(rar, fos, options, pdfDigest);
}
return Files.readAllBytes(tempTarget.toPath());
} finally {
Files.deleteIfExists(tempSource.toPath());
Files.deleteIfExists(tempTarget.toPath());
}
}
// Para PDFs de tamanho padrão (< 2MB), processa em memória com buffer pré-alocado
ByteArrayOutputStream finalOut = new ByteArrayOutputStream(pdfBytes.length + 65536);
try (RandomAccessReadBuffer rar = new RandomAccessReadBuffer(pdfBytes)) {
signPdfInternal(rar, finalOut, options, pdfDigest);
}
logger.info("Assinatura PAdES concluída com sucesso. Tamanho final: {} bytes", finalOut.size());
return finalOut.toByteArray();
}
private void signPdfInternal(RandomAccessRead rar, OutputStream targetOut,
PdfSignatureOptions options, byte[] pdfDigest) throws Exception {
// 1. Localiza o certificado e o material criptográfico (chave e cadeia) // 1. Localiza o certificado e o material criptográfico (chave e cadeia)
Optional<ManagedCertificate> certOpt = certificateManager.findById(options.certificateId()); Optional<ManagedCertificate> certOpt = certificateManager.findById(options.certificateId());
if (certOpt.isEmpty()) { if (certOpt.isEmpty()) {
@@ -186,8 +284,6 @@ public class PdfSigningService {
String rawName = cert.commonName() != null ? cert.commonName() : cert.alias(); String rawName = cert.commonName() != null ? cert.commonName() : cert.alias();
String signerName = X509CertificateUtils.extractHolderName(rawName); String signerName = X509CertificateUtils.extractHolderName(rawName);
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
byte[] pdfDigest = sha256.digest(pdfBytes);
String hashShort = bytesToHex(pdfDigest).substring(0, 16).toUpperCase(); String hashShort = bytesToHex(pdfDigest).substring(0, 16).toUpperCase();
PdfSealTheme theme = options.sealTheme(); PdfSealTheme theme = options.sealTheme();
@@ -213,8 +309,7 @@ public class PdfSigningService {
// preservando intactas quaisquer assinaturas digitais pré-existentes. Em conformidade com a ISO 32000-1 / ETSI PAdES, // preservando intactas quaisquer assinaturas digitais pré-existentes. Em conformidade com a ISO 32000-1 / ETSI PAdES,
// o selo visual é associado diretamente ao widget da assinatura (/Widget /AP /N) sem alterar o stream /Contents // o selo visual é associado diretamente ao widget da assinatura (/Widget /AP /N) sem alterar o stream /Contents
// das páginas existentes, garantindo que o validador do ITI aprove a verificação incremental sem indeterminação. // das páginas existentes, garantindo que o validador do ITI aprove a verificação incremental sem indeterminação.
ByteArrayOutputStream finalOut = new ByteArrayOutputStream(); try (PDDocument doc = Loader.loadPDF(rar)) {
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
PDSignature signature = new PDSignature(); PDSignature signature = new PDSignature();
signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE); signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE);
signature.setSubFilter(PDSignature.SUBFILTER_ADBE_PKCS7_DETACHED); signature.setSubFilter(PDSignature.SUBFILTER_ADBE_PKCS7_DETACHED);
@@ -235,16 +330,13 @@ public class PdfSigningService {
signatureOptions.setVisualSignature(visualTemplate); signatureOptions.setVisualSignature(visualTemplate);
signatureOptions.setPage(targetPage); signatureOptions.setPage(targetPage);
doc.addSignature(signature, signer, signatureOptions); doc.addSignature(signature, signer, signatureOptions);
doc.saveIncremental(finalOut); doc.saveIncremental(targetOut);
} }
} else { } else {
doc.addSignature(signature, signer); doc.addSignature(signature, signer);
doc.saveIncremental(finalOut); doc.saveIncremental(targetOut);
} }
} }
logger.info("Assinatura PAdES concluída com sucesso. Tamanho final: {} bytes", finalOut.size());
return finalOut.toByteArray();
} }
private int resolveTargetPage(PDDocument doc, PdfSignatureOptions options) { private int resolveTargetPage(PDDocument doc, PdfSignatureOptions options) {
@@ -289,4 +289,118 @@ class PdfSigningServiceTest {
assertThat(validationResult.signers().get(1).digestValid()).isTrue(); assertThat(validationResult.signers().get(1).digestValid()).isTrue();
assertThat(validationResult.signers().get(1).signerName()).contains("Maria Souza"); assertThat(validationResult.signers().get(1).signerName()).contains("Maria Souza");
} }
@Test
@DisplayName("Deve assinar arquivo PDF via signPdfFile com arquivos de origem e destino distintos")
void shouldSignPdfFileWithDistinctSourceAndTarget() throws Exception {
java.io.File src = java.io.File.createTempFile("cybercore_src_", ".pdf");
java.io.File tgt = java.io.File.createTempFile("cybercore_tgt_", ".pdf");
try {
java.nio.file.Files.write(src.toPath(), createSamplePdf());
PdfSignatureOptions options = PdfSignatureOptions.builder(testCertId)
.visibleSeal(true)
.position(PdfSealPosition.BOTTOM_RIGHT)
.reason("Assinatura de Arquivo Físico")
.location("Brasília, DF")
.build();
pdfSigningService.signPdfFile(src, tgt, options);
assertThat(tgt.exists()).isTrue();
assertThat(tgt.length()).isGreaterThan(src.length());
// Valida leitura de páginas e dimensões a partir de File
assertThat(PdfSigningService.getPageCount(src)).isEqualTo(1);
assertThat(PdfSigningService.getPageCount(tgt)).isEqualTo(1);
PdfSigningService.PageDimensions dims = PdfSigningService.getPageDimensions(tgt, 0);
assertThat(dims.width()).isGreaterThan(0);
assertThat(dims.height()).isGreaterThan(0);
// Validação criptográfica do arquivo resultante
byte[] signedBytes = java.nio.file.Files.readAllBytes(tgt.toPath());
SignatureValidationService validationService = new SignatureValidationService();
ValidationResult res = validationService.validateAttached(signedBytes, "arquivo_assinado.pdf");
assertThat(res.isValid()).isTrue();
} finally {
java.nio.file.Files.deleteIfExists(src.toPath());
java.nio.file.Files.deleteIfExists(tgt.toPath());
}
}
@Test
@DisplayName("Deve assinar arquivo PDF in-place (mesmo arquivo de origem e destino)")
void shouldSignInPlaceWhenSourceAndTargetAreSameFile() throws Exception {
java.io.File file = java.io.File.createTempFile("cybercore_inplace_", ".pdf");
try {
byte[] sample = createSamplePdf();
java.nio.file.Files.write(file.toPath(), sample);
long initialSize = file.length();
PdfSignatureOptions options = PdfSignatureOptions.builder(testCertId)
.visibleSeal(false)
.reason("Assinatura In-Place")
.build();
pdfSigningService.signPdfFile(file, file, options);
assertThat(file.exists()).isTrue();
assertThat(file.length()).isGreaterThan(initialSize);
byte[] signedBytes = java.nio.file.Files.readAllBytes(file.toPath());
SignatureValidationService validationService = new SignatureValidationService();
ValidationResult res = validationService.validateAttached(signedBytes, "inplace.pdf");
assertThat(res.isValid()).isTrue();
} finally {
java.nio.file.Files.deleteIfExists(file.toPath());
}
}
@Test
@DisplayName("Deve assinar PDF grande (> 2MB) acionando a estratégia de arquivo temporário")
void shouldSignLargePdfTriggeringTempFileOptimization() throws Exception {
// Cria PDF com buffer grande embutido para ultrapassar 2MB
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 10);
cs.newLineAtOffset(50, 700);
cs.showText("PDF com carga útil volumosa para teste de threshold de streaming.");
cs.endText();
}
// Anexa stream arbitrário para elevar o tamanho acima de 2MB
org.apache.pdfbox.pdmodel.common.PDStream stream = new org.apache.pdfbox.pdmodel.common.PDStream(doc);
try (java.io.OutputStream os = stream.createOutputStream()) {
byte[] chunk = new byte[65536];
java.util.Arrays.fill(chunk, (byte) 'X');
for (int i = 0; i < 35; i++) { // ~2.2 MB
os.write(chunk);
}
}
doc.getDocumentCatalog().getCOSObject().setItem(
org.apache.pdfbox.cos.COSName.getPDFName("LargePayload"), stream.getCOSObject()
);
doc.save(baos);
}
byte[] largePdf = baos.toByteArray();
assertThat(largePdf.length).isGreaterThan(2 * 1024 * 1024);
PdfSignatureOptions options = PdfSignatureOptions.builder(testCertId)
.visibleSeal(true)
.position(PdfSealPosition.BOTTOM_RIGHT)
.reason("Assinatura de Arquivo Grande")
.build();
byte[] signed = pdfSigningService.signPdf(largePdf, options);
assertThat(signed).isNotNull();
assertThat(signed.length).isGreaterThan(largePdf.length);
SignatureValidationService validationService = new SignatureValidationService();
ValidationResult res = validationService.validateAttached(signed, "large_signed.pdf");
assertThat(res.isValid()).isTrue();
}
} }