From 5360caaddf3de4655b042ddeb83abfb786190744 Mon Sep 17 00:00:00 2001 From: Claudecio Martins Date: Sun, 11 Oct 2026 10:43:59 -0300 Subject: [PATCH] =?UTF-8?q?fix(pdf):=20co-assinatura=20incremental=20com?= =?UTF-8?q?=20preserva=C3=A7=C3=A3o=20de=20assinaturas=20anteriores=20e=20?= =?UTF-8?q?trava=20anti-salto=20no=20redimensionamento?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../signer/core/pdf/PdfSigningService.java | 169 +++++++++--------- .../core/pdf/PdfSigningServiceTest.java | 78 +++++++- .../signer/ui/view/PdfSignDialog.java | 96 +++++++++- 3 files changed, 254 insertions(+), 89 deletions(-) diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/PdfSigningService.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/PdfSigningService.java index 7e3984a..260b2cb 100644 --- a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/PdfSigningService.java +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/PdfSigningService.java @@ -195,18 +195,16 @@ public class PdfSigningService { effectiveExtraInfo ); - // 4. Carrega o PDF e estampa o selo se solicitado - byte[] intermediatePdf; - if (options.visibleSeal()) { - byte[] sealPng = PdfSealRenderer.renderToPngBytes(theme, sealData); - intermediatePdf = applyVisualSealToPdf(pdfBytes, sealPng, theme, options); - } else { - intermediatePdf = pdfBytes; - } - - // 5. Aplica a assinatura PAdES criptográfica via PDFBox e Bouncy Castle + // 4. Carrega o PDF original uma única vez, estampa o selo se solicitado + // e aplica a nova assinatura PAdES via atualização incremental (Incremental Update), + // preservando intactas quaisquer assinaturas digitais pré-existentes. ByteArrayOutputStream finalOut = new ByteArrayOutputStream(); - try (PDDocument doc = Loader.loadPDF(intermediatePdf)) { + try (PDDocument doc = Loader.loadPDF(pdfBytes)) { + if (options.visibleSeal()) { + byte[] sealPng = PdfSealRenderer.renderToPngBytes(theme, sealData); + applyVisualSealToDocument(doc, sealPng, theme, options); + } + PDSignature signature = new PDSignature(); signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE); signature.setSubFilter(PDSignature.SUBFILTER_ADBE_PKCS7_DETACHED); @@ -224,93 +222,102 @@ public class PdfSigningService { return finalOut.toByteArray(); } - private byte[] applyVisualSealToPdf(byte[] pdfBytes, byte[] sealPng, PdfSealTheme theme, - PdfSignatureOptions options) throws IOException { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - try (PDDocument doc = Loader.loadPDF(pdfBytes)) { - int totalPages = doc.getNumberOfPages(); - if (totalPages == 0) { - return pdfBytes; - } + private void applyVisualSealToDocument(PDDocument doc, byte[] sealPng, PdfSealTheme theme, + PdfSignatureOptions options) throws IOException { + int totalPages = doc.getNumberOfPages(); + if (totalPages == 0) { + return; + } - PDImageXObject pdImage = PDImageXObject.createFromByteArray(doc, sealPng, "cybercore_seal"); + PDImageXObject pdImage = PDImageXObject.createFromByteArray(doc, sealPng, "cybercore_seal"); - float scale = (options.customSealScale() != null && options.customSealScale() > 0) - ? options.customSealScale().floatValue() - : 1.0f; - float sealWidth = (theme.width() * 0.72f) * scale; - float sealHeight = (theme.height() * 0.72f) * scale; - float margin = 20f; + float scale = (options.customSealScale() != null && options.customSealScale() > 0) + ? options.customSealScale().floatValue() + : 1.0f; + float sealWidth = (theme.width() * 0.72f) * scale; + float sealHeight = (theme.height() * 0.72f) * scale; + float margin = 20f; - // Se coordenadas customizadas foram informadas (clique ou drag & drop na página) - if (options.customX() != null && options.customY() != null) { - int targetPageIdx = totalPages - 1; - if (options.targetPageNumber() != null && options.targetPageNumber() >= 1 && options.targetPageNumber() <= totalPages) { - targetPageIdx = options.targetPageNumber() - 1; - } + // Se coordenadas customizadas foram informadas (clique ou drag & drop na página) + if (options.customX() != null && options.customY() != null) { + int targetPageIdx = totalPages - 1; + if (options.targetPageNumber() != null && options.targetPageNumber() >= 1 && options.targetPageNumber() <= totalPages) { + targetPageIdx = options.targetPageNumber() - 1; + } - PDPage page = doc.getPage(targetPageIdx); - PDRectangle mediaBox = page.getMediaBox(); - float pageW = mediaBox.getWidth(); - float pageH = mediaBox.getHeight(); + PDPage page = doc.getPage(targetPageIdx); + PDRectangle mediaBox = page.getMediaBox(); + float pageW = mediaBox.getWidth(); + float pageH = mediaBox.getHeight(); - float x = Math.max(0, Math.min(options.customX(), pageW - sealWidth)); - float y = Math.max(0, Math.min(options.customY(), pageH - sealHeight)); + float x = Math.max(0, Math.min(options.customX(), pageW - sealWidth)); + float y = Math.max(0, Math.min(options.customY(), pageH - sealHeight)); - try (PDPageContentStream cs = new PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true)) { - cs.drawImage(pdImage, x, y, sealWidth, sealHeight); - } + try (PDPageContentStream cs = new PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true)) { + cs.drawImage(pdImage, x, y, sealWidth, sealHeight); + } - doc.save(baos); - return baos.toByteArray(); - } + markPageAsUpdated(page); + return; + } - // Caso contrário, usa posicionamento por enum (BOTTOM_RIGHT, etc.) - int startPage = 0; - int endPage = totalPages - 1; + // Caso contrário, usa posicionamento por enum (BOTTOM_RIGHT, etc.) + int startPage = 0; + int endPage = totalPages - 1; - if (options.pageChoice() == PdfSealPageChoice.FIRST_PAGE) { - endPage = 0; - } else if (options.pageChoice() == PdfSealPageChoice.LAST_PAGE) { - startPage = totalPages - 1; - } + if (options.pageChoice() == PdfSealPageChoice.FIRST_PAGE) { + endPage = 0; + } else if (options.pageChoice() == PdfSealPageChoice.LAST_PAGE) { + startPage = totalPages - 1; + } - for (int i = startPage; i <= endPage; i++) { - PDPage page = doc.getPage(i); - PDRectangle mediaBox = page.getMediaBox(); - float pageW = mediaBox.getWidth(); - float pageH = mediaBox.getHeight(); + for (int i = startPage; i <= endPage; i++) { + PDPage page = doc.getPage(i); + PDRectangle mediaBox = page.getMediaBox(); + float pageW = mediaBox.getWidth(); + float pageH = mediaBox.getHeight(); - float x; - float y; + float x; + float y; - switch (options.position()) { - case BOTTOM_LEFT -> { - x = margin; - y = margin; - } - case TOP_RIGHT -> { - x = pageW - sealWidth - margin; - y = pageH - sealHeight - margin; - } - case TOP_LEFT -> { - x = margin; - y = pageH - sealHeight - margin; - } - default -> { // BOTTOM_RIGHT - x = pageW - sealWidth - margin; - y = margin; - } + switch (options.position()) { + case BOTTOM_LEFT -> { + x = margin; + y = margin; + } + case TOP_RIGHT -> { + x = pageW - sealWidth - margin; + y = pageH - sealHeight - margin; } + case TOP_LEFT -> { + x = margin; + y = pageH - sealHeight - margin; + } + default -> { // BOTTOM_RIGHT + x = pageW - sealWidth - margin; + y = margin; + } + } - try (PDPageContentStream cs = new PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true)) { - cs.drawImage(pdImage, x, y, sealWidth, sealHeight); - } + try (PDPageContentStream cs = new PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true)) { + cs.drawImage(pdImage, x, y, sealWidth, sealHeight); } - doc.save(baos); + markPageAsUpdated(page); + } + } + + private void markPageAsUpdated(PDPage page) { + if (page != null && page.getCOSObject() != null) { + page.getCOSObject().setNeedToBeUpdated(true); + if (page.getResources() != null && page.getResources().getCOSObject() != null) { + page.getResources().getCOSObject().setNeedToBeUpdated(true); + } + org.apache.pdfbox.cos.COSBase contents = page.getCOSObject().getDictionaryObject(org.apache.pdfbox.cos.COSName.CONTENTS); + if (contents instanceof org.apache.pdfbox.cos.COSUpdateInfo updateInfo) { + updateInfo.setNeedToBeUpdated(true); + } } - return baos.toByteArray(); } private static String bytesToHex(byte[] bytes) { diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/pdf/PdfSigningServiceTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/pdf/PdfSigningServiceTest.java index 6c061fc..97119be 100644 --- a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/pdf/PdfSigningServiceTest.java +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/pdf/PdfSigningServiceTest.java @@ -18,6 +18,8 @@ import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; import org.bouncycastle.operator.ContentSigner; import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import br.com.cybercore.signer.core.validation.SignatureValidationService; +import br.com.cybercore.signer.core.validation.ValidationResult; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -41,6 +43,7 @@ class PdfSigningServiceTest { private CertificateManager certificateManager; private PdfSigningService pdfSigningService; private String testCertId; + private String testCert2Id; @BeforeEach void setUp() throws Exception { @@ -74,13 +77,29 @@ class PdfSigningServiceTest { char[] pwd = "password123".toCharArray(); ks.setKeyEntry("test-cert", kp.getPrivate(), pwd, new Certificate[]{cert}); + // Gera segundo par de chaves e certificado para co-assinatura + KeyPair kp2 = kpg.generateKeyPair(); + X500Name subject2 = new X500Name("CN=Maria Souza:98765432100, O=CyberCore Testes, C=BR"); + X509v3CertificateBuilder certBuilder2 = new JcaX509v3CertificateBuilder( + subject2, + BigInteger.valueOf(System.currentTimeMillis() + 100), + notBefore, + notAfter, + subject2, + kp2.getPublic() + ); + ContentSigner signer2 = new JcaContentSignerBuilder("SHA256withRSA").build(kp2.getPrivate()); + X509Certificate cert2 = new JcaX509CertificateConverter().getCertificate(certBuilder2.build(signer2)); + ks.setKeyEntry("test-cert-2", kp2.getPrivate(), pwd, new Certificate[]{cert2}); + Pkcs12CertificateProvider provider = new Pkcs12CertificateProvider("test-provider", "Provedor de Teste"); provider.setKeyStore(ks, pwd); certificateManager.registerProvider(provider); List list = certificateManager.discoverCertificates(); - assertThat(list).isNotEmpty(); - this.testCertId = list.get(0).id(); + assertThat(list).hasSize(2); + this.testCertId = list.stream().filter(c -> c.alias().equals("test-cert")).findFirst().orElseThrow().id(); + this.testCert2Id = list.stream().filter(c -> c.alias().equals("test-cert-2")).findFirst().orElseThrow().id(); this.pdfSigningService = new PdfSigningService(certificateManager); } @@ -215,4 +234,59 @@ class PdfSigningServiceTest { byte[] pngBytes = PdfSigningService.renderPagePngBytes(originalPdf, 0, 72f); assertThat(pngBytes).isNotEmpty(); } + + @Test + @DisplayName("Deve manter ambas as assinaturas válidas ao assinar um PDF que já possui assinatura digital (Co-assinatura PAdES)") + void shouldKeepBothSignaturesValidWhenSigningAlreadySignedPdf() throws Exception { + byte[] originalPdf = createSamplePdf(); + + // 1ª Assinatura com o primeiro certificado + PdfSignatureOptions firstOptions = PdfSignatureOptions.builder(testCertId) + .visibleSeal(true) + .position(PdfSealPosition.BOTTOM_RIGHT) + .reason("Primeira Aprovação") + .location("Brasília, DF") + .build(); + + byte[] firstSignedPdf = pdfSigningService.signPdf(originalPdf, firstOptions); + assertThat(firstSignedPdf).isNotNull(); + + // 2ª Assinatura sobre o documento já assinado com o segundo certificado + PdfSignatureOptions secondOptions = PdfSignatureOptions.builder(testCert2Id) + .visibleSeal(true) + .position(PdfSealPosition.TOP_LEFT) + .reason("Segunda Aprovação Conjunta") + .location("São Paulo, SP") + .build(); + + byte[] doubleSignedPdf = pdfSigningService.signPdf(firstSignedPdf, secondOptions); + assertThat(doubleSignedPdf).isNotNull(); + assertThat(doubleSignedPdf.length).isGreaterThan(firstSignedPdf.length); + + // Validação estrutural das assinaturas no PDDocument + try (PDDocument doc = Loader.loadPDF(doubleSignedPdf)) { + List signatures = doc.getSignatureDictionaries(); + assertThat(signatures).hasSize(2); + assertThat(signatures.get(0).getName()).contains("Joao da Silva"); + assertThat(signatures.get(0).getReason()).isEqualTo("Primeira Aprovação"); + assertThat(signatures.get(1).getName()).contains("Maria Souza"); + assertThat(signatures.get(1).getReason()).isEqualTo("Segunda Aprovação Conjunta"); + } + + // Validação criptográfica rigorosa via SignatureValidationService (ambas as assinaturas devem ser íntegras) + SignatureValidationService validationService = new SignatureValidationService(); + ValidationResult validationResult = validationService.validateAttached(doubleSignedPdf, "duplo_assinado.pdf"); + + assertThat(validationResult.isValid()).isTrue(); + assertThat(validationResult.signers()).hasSize(2); + + // Ambas as assinaturas e digests devem ser 100% válidos + assertThat(validationResult.signers().get(0).signatureValid()).isTrue(); + assertThat(validationResult.signers().get(0).digestValid()).isTrue(); + assertThat(validationResult.signers().get(0).signerName()).contains("Joao da Silva"); + + assertThat(validationResult.signers().get(1).signatureValid()).isTrue(); + assertThat(validationResult.signers().get(1).digestValid()).isTrue(); + assertThat(validationResult.signers().get(1).signerName()).contains("Maria Souza"); + } } diff --git a/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/PdfSignDialog.java b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/PdfSignDialog.java index e075259..c91b0db 100644 --- a/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/PdfSignDialog.java +++ b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/PdfSignDialog.java @@ -144,6 +144,8 @@ public final class PdfSignDialog { private Label lblSealScaleIndicator; private double resizeStartSceneX; private double resizeStartBaseWidth; + private boolean isResizingSealInProgress = false; + private long lastResizeActionTimestamp = 0; // Visualizador Interativo da Folha private ScrollPane sheetScroll; @@ -547,12 +549,17 @@ public final class PdfSignDialog { interactiveOverlayPane = new Pane(); interactiveOverlayPane.setCursor(Cursor.CROSSHAIR); interactiveOverlayPane.setOnMouseClicked(event -> { - if (sealDragOccurred) { - // Previne que a soltura do mouse no término do arraste cause clique residual + if (sealDragOccurred || isResizingSealInProgress || (System.currentTimeMillis() - lastResizeActionTimestamp < 400)) { + // Previne que a soltura do mouse no término do redimensionamento ou arraste cause clique residual sealDragOccurred = false; event.consume(); return; } + if (isResizeModeActive) { + // No modo redimensionamento ativo, clicar fora não deve saltar o selo acidentalmente + event.consume(); + return; + } handleSheetClick(event); }); @@ -572,6 +579,10 @@ public final class PdfSignDialog { sealSelectionBorder.setStrokeWidth(1.8); sealSelectionBorder.getStrokeDashArray().addAll(5.0, 4.0); sealSelectionBorder.setVisible(false); + sealSelectionBorder.setOnMousePressed(e -> e.consume()); + sealSelectionBorder.setOnMouseDragged(e -> e.consume()); + sealSelectionBorder.setOnMouseReleased(e -> e.consume()); + sealSelectionBorder.setOnMouseClicked(e -> e.consume()); resizeHandle = new Circle(8); resizeHandle.setFill(Color.rgb(2, 132, 199)); @@ -611,10 +622,15 @@ public final class PdfSignDialog { event.consume(); }); - sealStampOverlayView.setOnMouseReleased(event -> event.consume()); + sealStampOverlayView.setOnMouseReleased(event -> { + lastResizeActionTimestamp = System.currentTimeMillis(); + event.consume(); + }); // Eventos da Alça de Redimensionamento Interativa resizeHandle.setOnMousePressed(event -> { + isResizingSealInProgress = true; + sealDragOccurred = true; resizeStartSceneX = event.getSceneX(); resizeStartBaseWidth = currentBaseSealWidth * zoomFactor * sealScale; event.consume(); @@ -622,17 +638,28 @@ public final class PdfSignDialog { resizeHandle.setOnMouseDragged(event -> { if (!chkVisibleSeal.isSelected() || selectedPdfBytes == null) return; + isResizingSealInProgress = true; + sealDragOccurred = true; double deltaX = event.getSceneX() - resizeStartSceneX; double newWidth = resizeStartBaseWidth + deltaX; double baseW = currentBaseSealWidth * zoomFactor; if (baseW > 0) { - applySealScale(newWidth / baseW); + applySealScalePreservingOrigin(newWidth / baseW); } event.consume(); }); - resizeHandle.setOnMouseReleased(event -> event.consume()); - resizeHandle.setOnMouseClicked(event -> event.consume()); + resizeHandle.setOnMouseReleased(event -> { + isResizingSealInProgress = false; + lastResizeActionTimestamp = System.currentTimeMillis(); + sealDragOccurred = true; + event.consume(); + }); + resizeHandle.setOnMouseClicked(event -> { + isResizingSealInProgress = false; + lastResizeActionTimestamp = System.currentTimeMillis(); + event.consume(); + }); sealGroupPane.getChildren().addAll(sealStampOverlayView, sealSelectionBorder, resizeHandle); interactiveOverlayPane.getChildren().add(sealGroupPane); @@ -647,6 +674,7 @@ public final class PdfSignDialog { private void toggleResizeMode() { this.isResizeModeActive = !this.isResizeModeActive; + this.lastResizeActionTimestamp = System.currentTimeMillis(); if (sealSelectionBorder != null) { sealSelectionBorder.setVisible(isResizeModeActive); } @@ -666,6 +694,62 @@ public final class PdfSignDialog { } } + private void applySealScalePreservingOrigin(double newScale) { + if (!chkVisibleSeal.isSelected() || selectedPdfBytes == null) return; + + double currentX = sealGroupPane != null ? sealGroupPane.getLayoutX() : 0; + double currentY = sealGroupPane != null ? sealGroupPane.getLayoutY() : 0; + + this.sealScale = Math.max(MIN_SEAL_SCALE, Math.min(MAX_SEAL_SCALE, Math.round(newScale * 100.0) / 100.0)); + if (lblSealScaleIndicator != null) { + lblSealScaleIndicator.setText(String.format("%.0f%%", sealScale * 100)); + } + if (btnResetSealScale != null) { + btnResetSealScale.setVisible(Math.abs(sealScale - 1.0) > 0.02); + } + + updateContainerZoomDimensions(); + + double sheetW = currentBaseSheetWidth * zoomFactor; + double sheetH = currentBaseSheetHeight * zoomFactor; + double sealW = currentBaseSealWidth * zoomFactor * sealScale; + double sealH = currentBaseSealHeight * zoomFactor * sealScale; + + // Mantém a quina superior esquerda ancorada no mesmo ponto exato + double clampedX = Math.max(0, Math.min(currentX, sheetW - sealW)); + double clampedY = Math.max(0, Math.min(currentY, sheetH - sealH)); + + if (sealGroupPane != null) { + sealGroupPane.setLayoutX(clampedX); + sealGroupPane.setLayoutY(clampedY); + sealGroupPane.setVisible(true); + } + + this.sealRelativeX = (sheetW > 0) ? (clampedX / sheetW) : 0; + this.sealRelativeY = (sheetH > 0) ? (clampedY / sheetH) : 0; + + PdfSealTheme theme = activeSealTheme != null ? activeSealTheme : PdfSealThemeRegistry.getDefaultTheme(); + float basePdfW = (float) ((theme != null) ? theme.width() * 0.72 : 144.0); + float basePdfH = (float) ((theme != null) ? theme.height() * 0.72 : 44.0); + float scaledPdfW = basePdfW * (float) sealScale; + float scaledPdfH = basePdfH * (float) sealScale; + + double scale = (currentPagePdfWidth > 0) ? (sheetW / currentPagePdfWidth) : 1.0; + float calculatedPdfX = (float) (clampedX / scale); + float calculatedPdfY = (float) (currentPagePdfHeight - (clampedY / scale) - scaledPdfH); + + this.customPdfX = Math.max(0, Math.min(calculatedPdfX, currentPagePdfWidth - scaledPdfW)); + this.customPdfY = Math.max(0, Math.min(calculatedPdfY, currentPagePdfHeight - scaledPdfH)); + this.customPageNumber = currentPageIndex + 1; + this.isCustomPositionActive = true; + + if (positionCombo.getValue() != PositionOption.CUSTOM) { + positionCombo.setValue(PositionOption.CUSTOM); + } + + updateFeedbackLabel(); + } + private void applySealScale(double newScale) { this.sealScale = Math.max(MIN_SEAL_SCALE, Math.min(MAX_SEAL_SCALE, Math.round(newScale * 100.0) / 100.0)); if (lblSealScaleIndicator != null) {