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
);
// 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) {
@@ -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<ManagedCertificate> 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<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");
}
}