Programação Por Equipe DevBras

Capa do artigo: Atualizou o iText e a assinatura de PDF quebrou: o Bouncy Castle mudou de lugar

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.

  1. Leia a página de breaking changes da versão de destino inteira, não só o resumo do release.
  2. Veja quem puxa o Bouncy Castle hoje com a árvore de dependências.
  3. Declare o iText e o adaptador com a mesma propriedade de versão.
  4. Exclua os artefatos jdk15on das dependências que ainda os trazem, e barre-os no build.
  5. Revise o código que importa org.bouncycastle diretamente; prefira as abstrações do iText quando houver.
  6. 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 jdk15on e jdk18on convivendo. Classes duplicadas em versões diferentes geram NoSuchMethodError que 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?

Avalie este artigo

Referência: https://kb.itextpdf.com/home/it7kb/releases/release-itext-core-8-0-0/breaking-changes-for-itext-core-8-0-0/bouncy-castle-changes