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_SENHAvem 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_fieldcria 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 doTextStampStyle, com o nome do certificado e a data preenchidos pela biblioteca. - PAdES.
SigSeedSubFilter.PADESgrava a assinatura no formatoETSI.CAdES.detached, o perfil europeu de assinatura de PDF que os validadores atuais esperam. Sem ele, o pyHanko usaadbe.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.
Referência: http://www.marcusmazzo.com.br/?p=190