- Go 72.2%
- Noir 19.8%
- Python 8%
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> |
||
|---|---|---|
| govbr | ||
| icp | ||
| proof1 | ||
| proof2 | ||
| tools | ||
| .gitignore | ||
| ACcompactado.zip | ||
| attack_test.go | ||
| br-roots.pem | ||
| build.go | ||
| Cadeia_GovBr-der.p7b | ||
| circuit_test.go | ||
| fixtures_test.go | ||
| go.mod | ||
| go.sum | ||
| govbr-roots.pem | ||
| govbr.pem | ||
| icp-brasil-roots.pem | ||
| icp-brasil.pem | ||
| main.go | ||
| main_test.go | ||
| proof2_witness.go | ||
| proof_build.go | ||
| proof_verify.go | ||
| README.md | ||
| sigextract.go | ||
| testca_test.go | ||
| verify.go | ||
| verify_test.go | ||
| vk_test.go | ||
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) == Hque contêm o token T entre os marcadoresBRISA-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. Averif_dateé onotBeforedo certificado, e o titular tinha pelo menos 18 anos nela. E o nullifier públiconullifier_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
notBeforepercorrendo o DER do TBS autenticado (der.nr); a testemunha já não fornece offsets. Ver "Segurança: v1 → v2". - v3 (2026-09-29):
claimspúblico — 1 PERSON (o nullifier), 2 ADULT (18+), 4 NAME (o CN do certificado, público emname/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.gogera 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
nargo1.0.0-beta.20 (vianoirup)bb5.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
- Faz o parse do PDF, extrai o CMS, roda a verificação básica de assinatura do CMS (Go).
- Extrai a witness da Proof2 (TBS, assinaturas, modulus, bytes de identidade) para um JSON temporário.
- Gera
proof1/Prover.tomla partir do PDF (proof1/gen_witness.py). - Gera
proof2/Prover.tomla partir do JSON da witness (tools/gen_witness.py, que também calcula offsets DER e o redc Barrett do efêmero). nargo executeem ambos os circuitos (resolve a witness; ~1s cada).bb prove --write_vkem 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
- Proof1 é uma prova ZK válida (
bb verify). - Proof2 é uma prova ZK válida (
bb verify). H(hash do documento) é idêntico nos public inputs das duas provas — amarra ambas ao mesmo PDF.- O
app_domainda Proof2 bate com o esperado pelo servidor. - O
verif_dateda Proof2 (onotBeforedo certificado, que o circuito lê) está dentro da janela de freshness (default 30 dias). - O
nullifier_zkda Proof2 ainda não está no arquivo-seen-nullifiers. - 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 usasha256WithRSAEncryption. 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)ondek = 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_rsaoriginal está abandonado; use o fork mantidozkpassport/noir_rsav0.11.0 (que pina transitivamente onoir-bignumv0.10.0, a versão que compila no nargo beta-20). BigNumParams::newcom modulus todo zero faz underflow em tempo de compilação viaget_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_vksobre o circuito que compilou) e passa-as em-p1-vke-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}eproof2/{proof,public_inputs}(~30 KB no total); servidor roda os doisbb verifye os 4 checks de aplicação. - Substitua o arquivo plano
-seen-nullifierspor 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.