Atualizou o iText e a assinatura de PDF quebrou: o Bouncy Castle mudou de lugar
Na versão 8 do iText, o Bouncy Castle passou a entrar por um adaptador explícito. Veja como alinhar as dependências, barrar a versão antiga e testar assinatura no CI.
O robô de dependências abriu um pull request subindo o iText de 7.2 para 8. O build passou, os testes de geração de PDF passaram, alguém aprovou. Na primeira nota fiscal do dia seguinte, o serviço de assinatura caiu com uma exceção dizendo que faltava uma dependência do Bouncy Castle. Nada estava errado no código. A nota de versão avisava que a criptografia tinha mudado de lugar, e ninguém leu além do número.
A resposta curta: a partir do iText 8, o Bouncy Castle deixou de vir junto de forma implícita e passou a ser ligado por um módulo adaptador que você declara de propósito (bouncy-castle-adapter, ou a variante FIPS). Ao mesmo tempo, o próprio Bouncy Castle trocou o nome dos artefatos Java de -jdk15on para -jdk18on. Declare o adaptador, alinhe as versões numa propriedade só, barre os artefatos antigos no build e coloque no CI um teste que assina e valida um PDF.
O que mudou e por que o build verde engana
Assinatura digital de PDF depende de duas bibliotecas trabalhando juntas. O iText monta o documento, reserva o espaço da assinatura e calcula o que deve ser assinado. O Bouncy Castle cuida da parte criptográfica: estruturas CMS, certificados, carimbo de tempo. Por anos, a dependência do iText puxava o Bouncy Castle como dependência transitiva, e quase ninguém precisava pensar nisso.
Na versão 8, o iText colocou uma camada de abstração entre o seu código de assinatura e o Bouncy Castle. Com isso, dá para escolher entre a implementação comum e a certificada FIPS, mas a escolha passou a ser sua. Se nenhum adaptador estiver no classpath, o código compila e falha em tempo de execução, justamente na primeira chamada de assinatura. Se o seu código importa classes de org.bouncycastle diretamente e a versão que vinha de carona sumiu, aí sim o build quebra.
A segunda mudança é do Bouncy Castle. As versões novas para Java são publicadas com o sufixo jdk18on; as antigas, com jdk15on. Para o Maven e o Gradle, são artefatos diferentes. Se uma biblioteca do projeto ainda puxa bcprov-jdk15on e o adaptador traz bcprov-jdk18on, as duas entram, com as mesmas classes em versões diferentes. O resultado é o pior tipo de erro: NoSuchMethodError num ambiente e funcionamento normal noutro, dependendo da ordem do classpath.
No .NET a lógica é a mesma: o adaptador do Bouncy Castle é um pacote NuGet separado, que você referencia junto do pacote principal do iText. A página de mudanças da versão 8, que é a sourceUrl deste post, lista os nomes exatos dos pacotes para cada plataforma; confira lá antes de subir.
Como atualizar sem surpresa, passo a passo
O exemplo é o serviço Assinador da Contábil Norte, em Java com Maven, que assina recibos em PDF com um certificado A1.
- Leia a página de breaking changes da versão de destino inteira, não só o resumo do release.
- Veja quem puxa o Bouncy Castle hoje com a árvore de dependências.
- Declare o iText e o adaptador com a mesma propriedade de versão.
- Exclua os artefatos
jdk15ondas dependências que ainda os trazem, e barre-os no build. - Revise o código que importa
org.bouncycastlediretamente; prefira as abstrações do iText quando houver. - Rode o teste de assinar e validar no CI, e só então aprove o update.
Ver quem puxa o Bouncy Castle
mvn dependency:tree -Dincludes=org.bouncycastle
No Assinador, a saída mostrou dois caminhos: o adaptador do iText trazendo bcprov-jdk18on e uma biblioteca antiga de leitura de XML trazendo bcprov-jdk15on. Com Gradle, o equivalente é ./gradlew dependencies --configuration runtimeClasspath e procurar por bouncycastle. No .NET, dotnet list package --include-transitive mostra os pacotes transitivos.
Declarar as versões juntas
<properties>
<itext.version>8.0.5</itext.version>
</properties>
<dependencies>
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>kernel</artifactId>
<version>${itext.version}</version>
</dependency>
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>sign</artifactId>
<version>${itext.version}</version>
</dependency>
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>bouncy-castle-adapter</artifactId>
<version>${itext.version}</version>
</dependency>
<dependency>
<groupId>com.exemplo</groupId>
<artifactId>leitor-xml-legado</artifactId>
<version>2.3.1</version>
<exclusions>
<exclusion>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15on</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
O número 8.0.5 é ilustrativo: use a versão que você testou. O que importa é que o iText e o adaptador saiam da mesma propriedade, para que ninguém suba um sem o outro. Exclua o artefato antigo só depois de confirmar que a biblioteca legada funciona com a versão nova do Bouncy Castle; se não funcionar, você descobriu cedo que ela também precisa de atualização.
Barrar o artefato antigo para sempre
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<id>sem-bouncy-castle-antigo</id>
<goals><goal>enforce</goal></goals>
<configuration>
<rules>
<bannedDependencies>
<excludes>
<exclude>org.bouncycastle:bcprov-jdk15on</exclude>
<exclude>org.bouncycastle:bcpkix-jdk15on</exclude>
<exclude>org.bouncycastle:bcutil-jdk15on</exclude>
</excludes>
</bannedDependencies>
</rules>
</configuration>
</execution>
</executions>
</plugin>
Com essa regra, a próxima biblioteca que tentar trazer o artefato antigo quebra o build com uma mensagem clara, em vez de quebrar a assinatura em produção.
O teste que o build verde não substitui
Gerar o PDF não prova que ele está assinado. O teste que importa assina um documento conhecido com um certificado de teste e depois valida a assinatura com uma leitura independente.
@Test
void assinaEValidaRecibo(@TempDir Path dir) throws Exception {
Path saida = dir.resolve("recibo-assinado.pdf");
assinador.assinar(Path.of("src/test/resources/recibo.pdf"), saida);
try (PdfDocument pdf = new PdfDocument(new PdfReader(saida.toFile()))) {
SignatureUtil util = new SignatureUtil(pdf);
List<String> nomes = util.getSignatureNames();
assertEquals(1, nomes.size());
String nome = nomes.get(0);
PdfPKCS7 pkcs7 = util.readSignatureData(nome);
assertTrue(pkcs7.verifySignatureIntegrityAndAuthenticity());
assertTrue(util.signatureCoversWholeDocument(nome));
}
}
O certificado de teste fica em src/test/resources, é autoassinado, não vale para nada fora do teste e a senha dele pode ficar no código de teste. O certificado real nunca entra no repositório. Para ir além da integridade e checar cadeia de confiança e revogação, veja conferir assinatura de PDF: válido não é confiável. Para o desenho do serviço de assinatura em si, assinar PDF sem entregar a chave.
Vale lembrar que o iText é distribuído sob licença AGPL ou comercial. Uma atualização de major é um bom momento para confirmar que o uso no seu produto continua coberto pela licença que vocês têm.
Erros comuns
- Subir o iText sem declarar o adaptador. O código compila e a assinatura falha em produção, na primeira chamada real.
- Deixar
jdk15onejdk18onconvivendo. Classes duplicadas em versões diferentes geramNoSuchMethodErrorque aparece só em alguns ambientes. - Versões soltas para o iText e o adaptador. Alguém sobe um sem o outro e a incompatibilidade só aparece em execução.
- Testar só a geração do PDF. O arquivo sai bonito, sem assinatura ou com assinatura inválida, e o CI fica verde.
- Aprovar o pull request do robô pelo número da versão. Major com breaking change documentado passa sem ninguém ler a página de mudanças.
- Colocar o certificado de produção no teste. A chave privada vai para o repositório e para o cache do CI.
Checklist
- A página de breaking changes da versão de destino foi lida e linkada no pull request.
- O adaptador do Bouncy Castle está declarado explicitamente.
- iText e adaptador usam a mesma propriedade de versão.
- A árvore de dependências não mostra artefatos
jdk15on. - O build barra os artefatos antigos com uma regra do enforcer ou equivalente.
- Existe teste que assina e valida um PDF, rodando no CI.
- O certificado do teste é descartável e o de produção não está no repositório.
Abra o pipeline do seu serviço de assinatura: existe um passo que assina um PDF e confere a assinatura, ou só um passo que compila?