Programação Por Equipe DevBras

Capa do artigo: Assinar PDF com Python: certificado A1 e pyHanko

Assinar PDF com Python: certificado A1 e pyHanko

Assine PDF de verdade em Python com pyHanko e certificado A1: campo visível, PAdES e carimbo do tempo, conferindo o resultado no pdfsig a cada passo.

Quando um sistema "assina" contrato colando o PNG da rubrica no rodapé, o PDF fica com cara de assinado e nenhuma prova dentro. Dá para conferir em um segundo com o pdfsig, do pacote Poppler:

$ pdfsig contrato.pdf
File 'contrato.pdf' does not contain any signatures

Qualquer pessoa copia a imagem para outro arquivo, e mudar uma cláusula depois não deixa rastro. Uma assinatura digital é outra coisa: um bloco criptográfico dentro do PDF, gerado com a chave privada de um certificado, que cobre os bytes do documento e diz quem assinou. Este guia faz isso em Python com o pyHanko, do certificado de teste ao carimbo do tempo, com o resultado conferido pelo próprio pdfsig.

Versões usadas no teste: pyHanko 0.38.0 com pyhanko-cli 0.6.0, Poppler 25.03 e um contrato de exemplo (nº 2026-118, R$ 3.200,00 por mês) gerado em PDF pelo Chrome.

O certificado: A1 para o servidor, de teste para começar

No Brasil, os dois tipos mais vendidos de certificado ICP-Brasil, segundo as perguntas frequentes do ITI, são o A1, armazenado no computador ou no celular e válido por 1 ano, e o A3, guardado em cartão, token ou na nuvem (HSM remoto), com validade de 1 a 5 anos. Para assinar dentro de um sistema, o A1 é o caminho natural: ele chega como um arquivo .pfx protegido por senha, que a biblioteca abre.

Para desenvolver não precisa comprar nada. O OpenSSL gera um certificado autoassinado e o empacota no mesmo formato de um A1:

openssl req -x509 -newkey rsa:2048 -nodes -keyout chave.pem -out cert.pem \
  -days 30 -subj "/C=BR/O=Loja Teste/CN=Loja Teste Ltda"
openssl pkcs12 -export -inkey chave.pem -in cert.pem \
  -out certificado-a1.pfx -passout pass:senha-do-pfx

A assinatura feita com ele é criptograficamente correta, mas nenhum validador vai confiar no emissor. É o esperado: serve para testar o fluxo, não para valer como assinatura de alguém.

Assinar em uma linha, pela linha de comando

Antes de escrever código, a CLI do pyHanko mostra se o ambiente está certo. O comando cria o campo de assinatura e assina com a chave e o certificado em PEM:

pip install pyhanko pyhanko-cli
pyhanko sign addsig --field Assinatura1 pemder \
  --key chave.pem --cert cert.pem --no-pass contrato.pdf contrato-assinado.pdf

O pdfsig contrato-assinado.pdf já mostra Total document signed e Signature is Valid.. Na versão 0.38 a CLI é um pacote separado; sem o pyhanko-cli, o comando pyhanko não existe.

O script: PFX, campo visível, PAdES e carimbo do tempo

No sistema, o script abaixo faz o mesmo com o .pfx e acrescenta o que um contrato de verdade pede:

# assinar.py: assina contrato.pdf com um certificado A1 (.pfx) usando pyHanko
import os
from pyhanko.sign import signers, timestamps, fields
from pyhanko.sign.fields import SigFieldSpec
from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter
from pyhanko.sign.signers.pdf_signer import PdfSignatureMetadata
from pyhanko.sign.fields import SigSeedSubFilter
from pyhanko.stamp import TextStampStyle

signer = signers.SimpleSigner.load_pkcs12(
    "certificado-a1.pfx", passphrase=os.environ["PFX_SENHA"].encode())

tsa = timestamps.HTTPTimeStamper("https://freetsa.org/tsr")

with open("contrato.pdf", "rb") as entrada:
    w = IncrementalPdfFileWriter(entrada)
    # campo visível no rodapé da página 1 (coordenadas em pontos, origem embaixo à esquerda)
    fields.append_signature_field(w, SigFieldSpec("AssinaturaContratada", on_page=0, box=(56, 60, 300, 120)))
    meta = PdfSignatureMetadata(field_name="AssinaturaContratada",
                                subfilter=SigSeedSubFilter.PADES,
                                reason="Aceite do contrato 2026-118",
                                location="São Paulo, SP")
    with open("contrato-assinado-a1.pdf", "wb") as saida:
        signers.PdfSigner(meta, signer=signer, timestamper=tsa,
                          stamp_style=TextStampStyle(stamp_text="Assinado digitalmente por %(signer)s\nData: %(ts)s")).sign_pdf(w, output=saida)
print("ok")

Parte por parte:

  • Senha fora do código. PFX_SENHA vem do ambiente. A chave privada é o que permite assinar em nome da empresa; onde guardar o arquivo e a senha num servidor está em assinar PDF no servidor sem deixar a chave à solta.
  • Campo visível. append_signature_field cria o retângulo no rodapé da página 1. As coordenadas são em pontos (1/72 de polegada), contadas a partir do canto inferior esquerdo. O texto do carimbo sai do TextStampStyle, com o nome do certificado e a data preenchidos pela biblioteca.
  • PAdES. SigSeedSubFilter.PADES grava a assinatura no formato ETSI.CAdES.detached, o perfil europeu de assinatura de PDF que os validadores atuais esperam. Sem ele, o pyHanko usa adbe.pkcs7.detached, o formato antigo.
  • Carimbo do tempo. A data que o computador põe na assinatura é só a palavra de quem assinou. O carimbo do tempo é emitido por uma terceira parte: segundo o ITI, ele comprova que a informação existia numa data e hora e é emitido por uma terceira parte confiável, a Autoridade Certificadora do Tempo (ACT). No teste foi usado o serviço público e gratuito do FreeTSA; em produção, contrate um serviço de carimbo do tempo reconhecido.

Rodando com PFX_SENHA=senha-do-pfx python assinar.py, o resultado:

$ pdfsig contrato-assinado-a1.pdf
Signature #1:
  - Signature Field Name: AssinaturaContratada
  - Signer Certificate Common Name: Loja Teste Ltda
  - Signer full Distinguished Name: C=BR,O=Loja Teste,CN=Loja Teste Ltda
  - Signing Time: Oct 26 2026 09:21:36
  - Signing Hash Algorithm: SHA-256
  - Signature Type: ETSI.CAdES.detached
  - Signed Ranges: [0 - 32495], [51081 - 51725]
  - Total document signed
  - Signature Validation: Signature is Valid.
  - Certificate Validation: Certificate issuer isn't Trusted.

As duas faixas de Signed Ranges são o arquivo inteiro menos o espaço reservado onde a própria assinatura foi gravada. Total document signed quer dizer que nada foi acrescentado depois. A última linha é o certificado de teste sendo honesto.

O pyhanko sign validate, mais rigoroso, também confere o carimbo: "The timestamp token is cryptographically sound", com o aviso de que o certificado do FreeTSA não está numa lista de confiança. Por isso ele julga o conjunto inválido no teste, o que reforça a troca por um serviço reconhecido em produção.

Guardar a prova do lado do sistema

A coluna assinado = 1 no banco continua útil, só que como índice, não como prova. Junto dela vale gravar o hash SHA-256 do PDF assinado, o nome do campo e a data do carimbo. Se um dia alguém apresentar um arquivo diferente, o hash mostra na hora qual é o original.

Dois cuidados de rotina. O A1 vence em um ano e, com ele vencido, o script para de assinar; o alerta com antecedência está em certificado que vence. E o PDF que entra no script deve sair de um gerador que não mistura dados de clientes, como mostra o guia para gerar PDF no servidor com cuidado.

Assinado o arquivo, falta o outro lado: o que "assinatura válida" garante quando é você quem recebe o contrato, e como um acréscimo depois da assinatura aparece na conferência. Isso está no guia para conferir assinatura de PDF no validador do ITI e no pdfsig.

Avalie este artigo

Referência: http://www.marcusmazzo.com.br/?p=190