Zero-knowledge proofs (Noir/Barretenberg) that a gov.br-signed token belongs to a unique adult Brazilian citizen, without revealing their CPF
  • Go 72.2%
  • Noir 19.8%
  • Python 8%
Find a file
Ruben M. Damião d2f4654f5c govbr: segredo as a library -- the document, the signed PDF, the proofs
Document makes the one-page PDF a person signs on gov.br's Assinador:
a sentence to read before signing, and the token between its markers.
Open checks what comes back: a PAdES signature over the document, by
a certificate chaining to gov.br's L3, carrying a token. Prove makes
Proof1 and Proof2 from it with nargo and bb, in a temporary directory
removed when it returns -- every witness carries the signer's CPF --
and reports each step. CheckName says why a chosen name would be
refused before a proof is spent on it.

The witnesses are written in Go: the Python generators are no longer
needed on this path. Tested against a real gov.br signature (never
committed): both proofs verify.

main_test.go removes the circuit copy the tests compile, which stayed
in the temp directory after every run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:21:15 -03:00
govbr govbr: segredo as a library -- the document, the signed PDF, the proofs 2026-09-29 22:21:15 -03:00
icp segredo: prove a gov.br signature, without the CPF 2026-09-29 20:42:17 -03:00
proof1 segredo: prove a gov.br signature, without the CPF 2026-09-29 20:42:17 -03:00
proof2 segredo: prove a gov.br signature, without the CPF 2026-09-29 20:42:17 -03:00
tools segredo: prove a gov.br signature, without the CPF 2026-09-29 20:42:17 -03:00
.gitignore segredo: prove a gov.br signature, without the CPF 2026-09-29 20:42:17 -03:00
ACcompactado.zip segredo: prove a gov.br signature, without the CPF 2026-09-29 20:42:17 -03:00
attack_test.go segredo: prove a gov.br signature, without the CPF 2026-09-29 20:42:17 -03:00
br-roots.pem segredo: prove a gov.br signature, without the CPF 2026-09-29 20:42:17 -03:00
build.go segredo: prove a gov.br signature, without the CPF 2026-09-29 20:42:17 -03:00
Cadeia_GovBr-der.p7b segredo: prove a gov.br signature, without the CPF 2026-09-29 20:42:17 -03:00
circuit_test.go segredo: prove a gov.br signature, without the CPF 2026-09-29 20:42:17 -03:00
fixtures_test.go segredo: prove a gov.br signature, without the CPF 2026-09-29 20:42:17 -03:00
go.mod segredo: prove a gov.br signature, without the CPF 2026-09-29 20:42:17 -03:00
go.sum segredo: prove a gov.br signature, without the CPF 2026-09-29 20:42:17 -03:00
govbr-roots.pem segredo: prove a gov.br signature, without the CPF 2026-09-29 20:42:17 -03:00
govbr.pem segredo: prove a gov.br signature, without the CPF 2026-09-29 20:42:17 -03:00
icp-brasil-roots.pem segredo: prove a gov.br signature, without the CPF 2026-09-29 20:42:17 -03:00
icp-brasil.pem segredo: prove a gov.br signature, without the CPF 2026-09-29 20:42:17 -03:00
main.go segredo: prove a gov.br signature, without the CPF 2026-09-29 20:42:17 -03:00
main_test.go govbr: segredo as a library -- the document, the signed PDF, the proofs 2026-09-29 22:21:15 -03:00
proof2_witness.go segredo: prove a gov.br signature, without the CPF 2026-09-29 20:42:17 -03:00
proof_build.go segredo: prove a gov.br signature, without the CPF 2026-09-29 20:42:17 -03:00
proof_verify.go segredo: prove a gov.br signature, without the CPF 2026-09-29 20:42:17 -03:00
README.md segredo: prove a gov.br signature, without the CPF 2026-09-29 20:42:17 -03:00
sigextract.go segredo: prove a gov.br signature, without the CPF 2026-09-29 20:42:17 -03:00
testca_test.go segredo: prove a gov.br signature, without the CPF 2026-09-29 20:42:17 -03:00
verify.go segredo: prove a gov.br signature, without the CPF 2026-09-29 20:42:17 -03:00
verify_test.go segredo: prove a gov.br signature, without the CPF 2026-09-29 20:42:17 -03:00
vk_test.go segredo: prove a gov.br signature, without the CPF 2026-09-29 20:42:17 -03:00

segredo

Sistema de prova ZK que permite a um usuário provar "tenho um token T assinado pelo gov.br e sou um cidadão brasileiro adulto único", sem revelar CPF/identidade, e ainda assim deixando o servidor detectar submissões duplicadas.

Construído sobre Noir (nargo 1.0.0-beta.20) e Barretenberg (bb UltraHonk).

Arquitetura de duas provas

O protocolo divide a testemunha (witness) em duas provas ZK que compartilham um amarrador público H = SHA-256(/ByteRange) (o hash do corpo assinado do PDF):

  • Proof1 — "Conheço os bytes B com SHA-256(B) == H que contêm o token T entre os marcadores BRISA-TOKEN-BEGIN: e :BRISA-TOKEN-END."

    • 96k ACIR opcodes, 1,2M circuit_size. ~25s prove, 2,5 GB RAM, 14,6 KB de prova.
  • Proof2 — "Existe um certificado RSA-2048 efêmero (gov.br Assinador) assinado pelo gov.br AC Final hardcoded (L3 RSA-4096), cujo SAN otherName 2.16.76.1.3.1 começa por DOB e CPF. Esse certificado assinou os signedAttrs do CMS cujo messageDigest é igual a H. A verif_date é o notBefore do certificado, e o titular tinha pelo menos 18 anos nela. E o nullifier público nullifier_zk = SHA-256(app_domain || DOB || CPF)."

    • 29k ACIR opcodes, 216k circuit_size. ~3,5s prove, 360 MB RAM, 14,6 KB de prova.
    • v2 (2026-09-29): o circuito encontra DOB, CPF, a chave e o notBefore percorrendo o DER do TBS autenticado (der.nr); a testemunha já não fornece offsets. Ver "Segurança: v1 → v2".
    • v3 (2026-09-29): claims público — 1 PERSON (o nullifier), 2 ADULT (18+), 4 NAME (o CN do certificado, público em name/name_len). O que não é afirmado sai a zeros. tools/gen_witness.py witness.json [claims]. Usado pelo brisa (zkid, o resgate) e pelo apelido (o atestado e a troca de chave); fixtures_test.go gera as provas de teste deles.
    • NAME mostra o nome como a pessoa o usa (name.nr, -name): o primeiro nome e pelo menos um sobrenome que não seja partícula, palavras inteiras, na ordem do certificado ("RUBEN DAMIAO" de "RUBEN MERENDI DAMIAO"). O nome completo nunca precisa de ficar público. 57k opcodes.

O servidor verifica que ambas as provas são válidas e compartilham o mesmo H — é isso que amarra a identidade autenticada pelo gov.br ao corpo do documento.

Por que dividir?

Proof1 e Proof2 podem rodar de forma independente e têm perfis de custo muito diferentes. A divisão mantém a Proof1 pequena (barata de atualizar caso o formato do token mude) enquanto a Proof2 carrega toda a maquinaria pesada de PKI.

Modelo de ameaça do nullifier

nullifier_zk = SHA-256(app_domain || DOB || CPF) é determinístico por CPF e a falsificação é impossível porque o circuito lê os dois campos, na posição que o DER dita, de um certificado autenticado pela cadeia L3 do gov.br. O nome saiu (v2): a sua posição e comprimento eram escolhidos por quem prova, e uma mudança de nome fazia uma segunda pessoa.

Risco residual reconhecido: um adversário com uma base populacional brasileira vazada (por exemplo, o dump da Serasa Experian de 2021 com DOB+CPF de ~220M pessoas) recalcula o nullifier público de cada um. Escopo escolhido deliberadamente como anti-Sybil contra observador casual, não anti-deanonimização contra adversário com dados vazados da Receita. Veja a memória do projeto para a discussão de design.

Se precisar endurecer contra esse adversário, troque nullifier::compute por uma variante OPRF (servidor mantém um segredo fixo, cliente faz blinding do CPF) — o resto do circuito não muda.

Configuração

Toolchain

  • nargo 1.0.0-beta.20 (via noirup)
  • bb 5.0.0-nightly alinhado ao nargo 1.0.0-beta.20 (bbup -nv 1.0.0-beta.20)
  • Go 1.21+
  • Python 3 com o pacote cryptography

Veja ~/.claude/.../memory/reference_noir_setup.md para os contornos sem-sudo no WSL Ubuntu 22.04 (extração do libc++, jq estático, env vars).

Trust bundle

A cadeia de confiança gov.br (3 certs: raiz L1 + intermediária L2 + AC Final L3) está em govbr.pem na raiz do repo. Quando o gov.br rotacionar L3 (próxima previsão ~2032; veja a nota de infra de CRL na memória), regenerar:

curl http://repo.iti.br/docs/Cadeia_GovBr-der.p7b -o /tmp/cadeia.p7b
openssl pkcs7 -inform DER -in /tmp/cadeia.p7b -print_certs > govbr.pem
python3 tools/gen_l3_params.py govbr.pem proof2/src/l3_params.nr
cd proof2 && nargo compile --force

Build

go build -o segredo .
cd proof1 && nargo compile && cd ..
cd proof2 && nargo compile && cd ..

Como usar

O pipeline completo do lado cliente está empacotado num único comando:

segredo proof-build \
   -in caminho/para/pdf_assinado.pdf \
   -trust govbr.pem \
   -app-domain <hex-de-32-bytes> \
   -out diretorio_de_saida/

Lado servidor:

segredo proof-verify \
   -p1-dir diretorio_de_saida/proof1 \
   -p2-dir diretorio_de_saida/proof2 \
   -p1-vk <a vk da proof1 do próprio servidor> \
   -p2-vk <a vk da proof2 do próprio servidor> \
   -app-domain <hex-de-32-bytes> \
   -max-age-days 30 \
   -seen-nullifiers /var/lib/brisa/seen.txt

proof-build — o que ele faz

  1. Faz o parse do PDF, extrai o CMS, roda a verificação básica de assinatura do CMS (Go).
  2. Extrai a witness da Proof2 (TBS, assinaturas, modulus, bytes de identidade) para um JSON temporário.
  3. Gera proof1/Prover.toml a partir do PDF (proof1/gen_witness.py).
  4. Gera proof2/Prover.toml a partir do JSON da witness (tools/gen_witness.py, que também calcula offsets DER e o redc Barrett do efêmero).
  5. nargo execute em ambos os circuitos (resolve a witness; ~1s cada).
  6. bb prove --write_vk em ambos (Proof1 ~25s 2,5 GB; Proof2 ~5s 322 MB).

Saída: <out>/proof1/{proof, vk, public_inputs, vk_hash} e o mesmo sob <out>/proof2/.

proof-verify — o que ele checa

  1. Proof1 é uma prova ZK válida (bb verify).
  2. Proof2 é uma prova ZK válida (bb verify).
  3. H (hash do documento) é idêntico nos public inputs das duas provas — amarra ambas ao mesmo PDF.
  4. O app_domain da Proof2 bate com o esperado pelo servidor.
  5. O verif_date da Proof2 (o notBefore do certificado, que o circuito lê) está dentro da janela de freshness (default 30 dias).
  6. O nullifier_zk da Proof2 ainda não está no arquivo -seen-nullifiers.
  7. Imprime o token da Proof1 para a lógica de negócio downstream usar.

Cada rejeição sai com código não-zero e causa clara. Tempo esperado de verificação: menos de 2 segundos, RSS de pico ~9 MB.

Comandos de mais baixo nível

Para uso direto dos passos subjacentes:

# Verifica CMS + dumpa witness JSON da Proof2
segredo verify -in pdf_assinado.pdf -trust govbr.pem \
   -dump-witness witness.json -app-domain <hex32>

# Gera os Prover.toml
python3 proof1/gen_witness.py pdf_assinado.pdf > proof1/Prover.toml
python3 tools/gen_witness.py witness.json > proof2/Prover.toml

# Resolve witness + prove (por circuito)
cd proof1 && nargo execute proof1_witness
bb prove -b target/proof1.json -w target/proof1_witness.gz \
         -o /saida/proof1 --write_vk

Requisitos de hardware

Lado RAM Tempo CPU Disco (por submissão)
Cliente (build) ~2,5 GB pico ~30s em WSL 12-core ~30 KB de saída
Servidor (verify) ~9 MB <2s —

O pico do cliente é dominado pela Proof1 (2,5 GB). A Proof2 sozinha cabe em 322 MB — viável em mobile nativo e possivelmente em WASM no navegador.

Estrutura do projeto

segredo/
  README.md                      este arquivo
  *.go                           Go: build + verify + proof-build + proof-verify
  testca_test.go                 AC falsa com a estrutura DER do gov.br
  circuit_test.go                corre a proof2 (nargo execute) com o L3 falso
  attack_test.go                 o que a proof2 tem de recusar
  vk_test.go                     proof-verify com a vk do cliente
  govbr.pem                      cadeia gov.br (L1+L2+L3)
  icp-brasil-roots.pem           raízes ICP-Brasil tradicional (separadas)
  proof1/
    Nargo.toml                   dep: sha256
    src/main.nr                  circuito do token-no-payload
    gen_witness.py               PDF -> Prover.toml
  proof2/
    Nargo.toml                   sha256 + zkpassport/noir_rsa + noir-bignum
    src/
      main.nr                    orquestracao: 5 etapas
      der.nr                     percorre o DER do TBS: DOB, CPF, chave, notBefore
      cert_chain.nr              verify RSA-4096 L3 -> efemero (tbs_len variavel)
      signed_attrs.nr            verify RSA-2048 efemero -> attrs + bind H ancorado
      age.nr                     datas (DOB, notBefore) + check 18+
      nullifier.nr               SHA-256(domain || DOB || CPF)
      l3_params.nr               AUTO-GERADO: limbs do modulus + redc do L3
  tools/
    gen_l3_params.py             govbr.pem -> proof2/src/l3_params.nr
    gen_witness.py               witness.json -> proof2/Prover.toml

Notas críticas de implementação

  • Os links da cadeia gov.br L1 -> L2 -> L3 usam sha512WithRSAEncryption, mas L3 -> efêmero usa sha256WithRSAEncryption. A Opção A só valida o último link no circuito, então nunca precisamos de gadget de SHA-512. Isso foi uma surpresa boa que manteve a Proof2 pequena.
  • O Barrett redc do noir-bignum é redc = floor(2^(2*k + 6) / N) onde k = N.bit_length(). O +6 (BARRETT_REDUCTION_OVERFLOW_BITS) está no noir-bignum-paramgen e precisa ser aplicado, senão a witness não resolve com "remainder is_zero" lá no fundo da stack do bignum.
  • O noir-lang/noir_rsa original está abandonado; use o fork mantido zkpassport/noir_rsa v0.11.0 (que pina transitivamente o noir-bignum v0.10.0, a versão que compila no nargo beta-20).
  • BigNumParams::new com modulus todo zero faz underflow em tempo de compilação via get_double_modulus (result[N-1] -= 1). Modulus placeholder precisa ter o limb topo não-zero.
  • Comentários no fonte Noir devem ser ASCII puro. Setas Unicode, travessões em e em meia, e subscritos quebram a compilação.

Composição no servidor (mais detalhes)

proof-verify é intencionalmente um CLI; embeddar em um servico real é trivial:

  • As duas VKs são estáveis por versão de circuito — o servidor gera as suas (bb write_vk sobre o circuito que compilou) e passa-as em -p1-vk e -p2-vk. A v1 lia <dir>/vk, a do cliente: uma prova de um circuito vazio passava (vk_test.go).
  • Por requisição: cliente sobe proof1/{proof,public_inputs} e proof2/{proof,public_inputs} (~30 KB no total); servidor roda os dois bb verify e os 4 checks de aplicação.
  • Substitua o arquivo plano -seen-nullifiers por um banco de dados (SET do Redis, índice único do Postgres, etc.) para escala de produção.

Segurança: v1 → v2

Encontradas em revisão e demonstradas com uma AC falsa com a estrutura DER do gov.br (testca_test.go): a v1 aceitava todas estas testemunhas.

ataque contra a v1 correção na v2
A H escrito no padding depois de signed_attrs_len (só o offset contra o máximo era verificado): um PDF qualquer assinado pela vítima provava a identidade dela sobre o documento do atacante offset + 32 <= signed_attrs_len, os 15 bytes do atributo messageDigest antes de H, e signed_attrs é exactamente um SET DER
B proof-verify usava a vk do cliente -p1-vk/-p2-vk do servidor
C offsets de NOME, DOB e CPF livres: o CPF deslizava pelos 45 dígitos do 2.16.76.1.3.1 (mais nullifiers), o DOB lia mmss do notBefore (um menor passava nos 18), o nome truncado ou do emissor der.nr percorre o TBS desde o início; a testemunha não dá offsets
D verif_date livre: um documento de 2019 passava na janela de 30 dias verif_date é o notBefore do certificado
E TBS_LEN = 1036 fixo: só provava quem tivesse um nome e e-mail do mesmo tamanho tbs_len variável até 1536

attack_test.go tem estes ataques e os que a v2 tem de aguentar (e-mail com um otherName forjado, SAN acrescentado depois de tbs_len, outra AC, outra chave). go test . corre-os (precisa de nargo; -short salta-os).

Não é uma auditoria. Antes de usar com valor real: revisão por quem audita circuitos ZK, incluindo as dependências (noir_rsa, noir-bignum, sha256).

Caso de uso típico

A Brisa quer atestar que cada CPF brasileiro adulto submete no máximo um voto. O cliente assina um PDF com seu token via gov.br Assinador, gera as duas provas, manda pro servidor. O servidor verifica que o PDF foi assinado pelo gov.br por uma pessoa de 18+, registra o nullifier, e aceita o token. Numa segunda submissão, mesmo que a pessoa gere um novo PDF, o nullifier (= SHA-256(domain || DOB || CPF)) será o mesmo → rejeitado.