fix(pdf): co-assinatura incremental com preservação de assinaturas anteriores e trava anti-salto no redimensionamento

This commit is contained in:
Claudecio Martins
2026-10-11 10:43:59 -03:00
parent 4c30649598
commit 5360caaddf
3 changed files with 254 additions and 89 deletions
@@ -195,18 +195,16 @@ public class PdfSigningService {
effectiveExtraInfo effectiveExtraInfo
); );
// 4. Carrega o PDF e estampa o selo se solicitado // 4. Carrega o PDF original uma única vez, estampa o selo se solicitado
byte[] intermediatePdf; // 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(pdfBytes)) {
if (options.visibleSeal()) { if (options.visibleSeal()) {
byte[] sealPng = PdfSealRenderer.renderToPngBytes(theme, sealData); byte[] sealPng = PdfSealRenderer.renderToPngBytes(theme, sealData);
intermediatePdf = applyVisualSealToPdf(pdfBytes, sealPng, theme, options); applyVisualSealToDocument(doc, sealPng, theme, options);
} else {
intermediatePdf = pdfBytes;
} }
// 5. Aplica a assinatura PAdES criptográfica via PDFBox e Bouncy Castle
ByteArrayOutputStream finalOut = new ByteArrayOutputStream();
try (PDDocument doc = Loader.loadPDF(intermediatePdf)) {
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);
@@ -224,13 +222,11 @@ public class PdfSigningService {
return finalOut.toByteArray(); return finalOut.toByteArray();
} }
private byte[] applyVisualSealToPdf(byte[] pdfBytes, byte[] sealPng, PdfSealTheme theme, private void applyVisualSealToDocument(PDDocument doc, byte[] sealPng, PdfSealTheme theme,
PdfSignatureOptions options) throws IOException { PdfSignatureOptions options) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
int totalPages = doc.getNumberOfPages(); int totalPages = doc.getNumberOfPages();
if (totalPages == 0) { if (totalPages == 0) {
return pdfBytes; return;
} }
PDImageXObject pdImage = PDImageXObject.createFromByteArray(doc, sealPng, "cybercore_seal"); PDImageXObject pdImage = PDImageXObject.createFromByteArray(doc, sealPng, "cybercore_seal");
@@ -261,8 +257,8 @@ public class PdfSigningService {
cs.drawImage(pdImage, x, y, sealWidth, sealHeight); cs.drawImage(pdImage, x, y, sealWidth, sealHeight);
} }
doc.save(baos); markPageAsUpdated(page);
return baos.toByteArray(); return;
} }
// Caso contrário, usa posicionamento por enum (BOTTOM_RIGHT, etc.) // Caso contrário, usa posicionamento por enum (BOTTOM_RIGHT, etc.)
@@ -306,11 +302,22 @@ public class PdfSigningService {
try (PDPageContentStream cs = new PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true)) { try (PDPageContentStream cs = new PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
cs.drawImage(pdImage, x, y, sealWidth, sealHeight); cs.drawImage(pdImage, x, y, sealWidth, sealHeight);
} }
markPageAsUpdated(page);
}
} }
doc.save(baos); 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) { private static String bytesToHex(byte[] bytes) {
@@ -18,6 +18,8 @@ import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.operator.ContentSigner; import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; 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.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -41,6 +43,7 @@ class PdfSigningServiceTest {
private CertificateManager certificateManager; private CertificateManager certificateManager;
private PdfSigningService pdfSigningService; private PdfSigningService pdfSigningService;
private String testCertId; private String testCertId;
private String testCert2Id;
@BeforeEach @BeforeEach
void setUp() throws Exception { void setUp() throws Exception {
@@ -74,13 +77,29 @@ class PdfSigningServiceTest {
char[] pwd = "password123".toCharArray(); char[] pwd = "password123".toCharArray();
ks.setKeyEntry("test-cert", kp.getPrivate(), pwd, new Certificate[]{cert}); 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"); Pkcs12CertificateProvider provider = new Pkcs12CertificateProvider("test-provider", "Provedor de Teste");
provider.setKeyStore(ks, pwd); provider.setKeyStore(ks, pwd);
certificateManager.registerProvider(provider); certificateManager.registerProvider(provider);
List<ManagedCertificate> list = certificateManager.discoverCertificates(); List<ManagedCertificate> list = certificateManager.discoverCertificates();
assertThat(list).isNotEmpty(); assertThat(list).hasSize(2);
this.testCertId = list.get(0).id(); 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); this.pdfSigningService = new PdfSigningService(certificateManager);
} }
@@ -215,4 +234,59 @@ class PdfSigningServiceTest {
byte[] pngBytes = PdfSigningService.renderPagePngBytes(originalPdf, 0, 72f); byte[] pngBytes = PdfSigningService.renderPagePngBytes(originalPdf, 0, 72f);
assertThat(pngBytes).isNotEmpty(); 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<PDSignature> 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");
}
} }
@@ -144,6 +144,8 @@ public final class PdfSignDialog {
private Label lblSealScaleIndicator; private Label lblSealScaleIndicator;
private double resizeStartSceneX; private double resizeStartSceneX;
private double resizeStartBaseWidth; private double resizeStartBaseWidth;
private boolean isResizingSealInProgress = false;
private long lastResizeActionTimestamp = 0;
// Visualizador Interativo da Folha // Visualizador Interativo da Folha
private ScrollPane sheetScroll; private ScrollPane sheetScroll;
@@ -547,12 +549,17 @@ public final class PdfSignDialog {
interactiveOverlayPane = new Pane(); interactiveOverlayPane = new Pane();
interactiveOverlayPane.setCursor(Cursor.CROSSHAIR); interactiveOverlayPane.setCursor(Cursor.CROSSHAIR);
interactiveOverlayPane.setOnMouseClicked(event -> { interactiveOverlayPane.setOnMouseClicked(event -> {
if (sealDragOccurred) { if (sealDragOccurred || isResizingSealInProgress || (System.currentTimeMillis() - lastResizeActionTimestamp < 400)) {
// Previne que a soltura do mouse no término do arraste cause clique residual // Previne que a soltura do mouse no término do redimensionamento ou arraste cause clique residual
sealDragOccurred = false; sealDragOccurred = false;
event.consume(); event.consume();
return; return;
} }
if (isResizeModeActive) {
// No modo redimensionamento ativo, clicar fora não deve saltar o selo acidentalmente
event.consume();
return;
}
handleSheetClick(event); handleSheetClick(event);
}); });
@@ -572,6 +579,10 @@ public final class PdfSignDialog {
sealSelectionBorder.setStrokeWidth(1.8); sealSelectionBorder.setStrokeWidth(1.8);
sealSelectionBorder.getStrokeDashArray().addAll(5.0, 4.0); sealSelectionBorder.getStrokeDashArray().addAll(5.0, 4.0);
sealSelectionBorder.setVisible(false); 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 = new Circle(8);
resizeHandle.setFill(Color.rgb(2, 132, 199)); resizeHandle.setFill(Color.rgb(2, 132, 199));
@@ -611,10 +622,15 @@ public final class PdfSignDialog {
event.consume(); event.consume();
}); });
sealStampOverlayView.setOnMouseReleased(event -> event.consume()); sealStampOverlayView.setOnMouseReleased(event -> {
lastResizeActionTimestamp = System.currentTimeMillis();
event.consume();
});
// Eventos da Alça de Redimensionamento Interativa // Eventos da Alça de Redimensionamento Interativa
resizeHandle.setOnMousePressed(event -> { resizeHandle.setOnMousePressed(event -> {
isResizingSealInProgress = true;
sealDragOccurred = true;
resizeStartSceneX = event.getSceneX(); resizeStartSceneX = event.getSceneX();
resizeStartBaseWidth = currentBaseSealWidth * zoomFactor * sealScale; resizeStartBaseWidth = currentBaseSealWidth * zoomFactor * sealScale;
event.consume(); event.consume();
@@ -622,17 +638,28 @@ public final class PdfSignDialog {
resizeHandle.setOnMouseDragged(event -> { resizeHandle.setOnMouseDragged(event -> {
if (!chkVisibleSeal.isSelected() || selectedPdfBytes == null) return; if (!chkVisibleSeal.isSelected() || selectedPdfBytes == null) return;
isResizingSealInProgress = true;
sealDragOccurred = true;
double deltaX = event.getSceneX() - resizeStartSceneX; double deltaX = event.getSceneX() - resizeStartSceneX;
double newWidth = resizeStartBaseWidth + deltaX; double newWidth = resizeStartBaseWidth + deltaX;
double baseW = currentBaseSealWidth * zoomFactor; double baseW = currentBaseSealWidth * zoomFactor;
if (baseW > 0) { if (baseW > 0) {
applySealScale(newWidth / baseW); applySealScalePreservingOrigin(newWidth / baseW);
} }
event.consume(); event.consume();
}); });
resizeHandle.setOnMouseReleased(event -> event.consume()); resizeHandle.setOnMouseReleased(event -> {
resizeHandle.setOnMouseClicked(event -> event.consume()); 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); sealGroupPane.getChildren().addAll(sealStampOverlayView, sealSelectionBorder, resizeHandle);
interactiveOverlayPane.getChildren().add(sealGroupPane); interactiveOverlayPane.getChildren().add(sealGroupPane);
@@ -647,6 +674,7 @@ public final class PdfSignDialog {
private void toggleResizeMode() { private void toggleResizeMode() {
this.isResizeModeActive = !this.isResizeModeActive; this.isResizeModeActive = !this.isResizeModeActive;
this.lastResizeActionTimestamp = System.currentTimeMillis();
if (sealSelectionBorder != null) { if (sealSelectionBorder != null) {
sealSelectionBorder.setVisible(isResizeModeActive); 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) { private void applySealScale(double newScale) {
this.sealScale = Math.max(MIN_SEAL_SCALE, Math.min(MAX_SEAL_SCALE, Math.round(newScale * 100.0) / 100.0)); this.sealScale = Math.max(MIN_SEAL_SCALE, Math.min(MAX_SEAL_SCALE, Math.round(newScale * 100.0) / 100.0));
if (lblSealScaleIndicator != null) { if (lblSealScaleIndicator != null) {