feat(signatures): módulo completo de assinatura eletrônica

Porta o motor de assinatura do legado com fidelidade algorítmica
exata — hash-chain (SHA256 encadeado, seq bigserial define a ordem,
não timestamp), OTP (6 dígitos via crypto.randomInt, HMAC-SHA256 com
SIGNATURE_OTP_SECRET, TTL 5min, 5 tentativas, único ativo por vez) e
a máquina de estados de 19 estados do envelope + 8 do signatário —
tudo re-verificado contra o código-fonte real do legado por um
subagente dedicado antes de qualquer linha ser escrita, cruzado com
eden.md módulo 4.

5 tabelas novas (signature_envelopes/signers/documents/
otp_challenges/audit_events, esta última append-only via a mesma GUC
eden.allow_audit_mutation que já protege audit_log desde a Fase 1 —
canonicalização de hash extraída para packages/database/src/
hash-chain.ts, compartilhada entre as duas). Três adaptações
deliberadas ao EDEN (documentadas na migration): documentos vêm de
document_generations em vez de um módulo de anexo genérico que não
existe ainda; reseller_id em vez de reseller_registration_id; só a
versão correta (v2) da canonicalização, sem a v1 do legado (existia
lá só para tolerar hashes pré-correção de bug, EDEN nunca teve isso).

Certificado de conclusão renderizado no servidor via Chromium (não
client-side como no legado) — elimina o upload multipart do
certificado pronto. Os dois passos autenticados de finalização
(seal-checkpoint, finalize) continuam explícitos e manuais, não
automáticos, mesmo sendo tecnicamente possível agora — esse
checkpoint humano antes de uma finalização irreversível é
comportamento de negócio herdado do legado, não um detalhe do
desenho client-side do certificado que deixou de existir.

Fluxo público (token do link, nunca envelopeId/signerId do corpo da
requisição) e verificação pública (só para COMPLETED, recomputa
envelope_hash ao vivo, nomes mascarados) inteiros.

Verificado: 28 testes novos (15 puros — hash-chain/máquina de
estados/OTP/token — e 13 de ciclo de vida completo via HTTP real,
incluindo merge de PDF de verdade via pdf-lib contra saída real do
Chromium mockado). Suíte completa: 70/70 verdes. Testado ao vivo
através do container real, ponta a ponta, sem nenhum mock: criação →
convite → fluxo público completo dos dois signatários (OTP
recuperado via HMAC com o segredo real, já que SMTP não está
configurado neste ambiente) → SIGNED → seal-checkpoint → finalize
(certificado real via Chromium, merge real, upload real no MinIO) →
public-verify — SHA-256 do PDF final baixado bate byte a byte com o
gravado no Postgres, integrityValid:true.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx
This commit is contained in:
2026-09-04 13:44:43 -03:00
parent d46d44f01b
commit 20b114a0bf
32 changed files with 3232 additions and 12 deletions

View File

@@ -539,3 +539,33 @@ Teste de integração novo (`test/contracts-edit-lock.spec.ts`, 3 testes, papel
### Pendências
`contract_versions` (snapshot-on-signature, invariante nº4 do ADR-0006) segue deliberadamente adiado — precisa do desenho do fluxo de assinatura para não virar uma tabela vazia. `contract_amendments` (forma correta de alterar um contrato já assinado, em vez do override de super_admin) também segue adiado — depende de `contract_versions` primeiro.
---
## Módulo de Assinatura Eletrônica — completo (envelopes, signatários, OTP, hash-chain, finalização, verificação pública)
Data: 2026-09-04. Usuário pediu para seguir com a "questão da assinatura" — última peça do estudo do OrçaFácil. Dado o tamanho e a sensibilidade jurídica/criptográfica do módulo, os algoritmos de segurança (hash-chain, OTP, máquina de estados) foram re-verificados contra o código-fonte real do legado por um subagente `fork` dedicado, cruzado com `eden.md` módulo 4 (que já documentava o módulo com grande precisão, escrito numa leva anterior) — nenhuma linha de crypto foi escrita de memória.
### Schema (`packages/database/migrations/1788536022387_signatures.cjs`)
5 tabelas: `signature_envelopes` (19 estados, `company_id` obrigatório — diferente de `contracts.company_id`, que é opcional — porque `createEnvelope` recusa criar sem representante legal cadastrado), `signature_signers` (snapshot, nunca referência viva), `signature_documents` (cópia congelada + SHA-256), `signature_otp_challenges`, `signature_audit_events` (hash-chain, append-only via a mesma GUC `eden.allow_audit_mutation` que já protege o `audit_log` genérico desde a Fase 1 — reaproveitada, não duplicada). `packages/database/src/hash-chain.ts` novo, compartilhado entre `audit_log` e `signature_audit_events` (a canonicalização recursiva já existia em `audit.ts`, só foi extraída).
**Três adaptações deliberadas ao EDEN, documentadas no cabeçalho da migration** (tudo o mais — os 19 estados, a fórmula do hash-chain, o algoritmo de OTP — é fidelidade exata ao legado):
1. `signature_documents.source_generation_id` referencia `document_generations` (o pipeline de PDF já construído nesta sessão) em vez do `source_attachment_id` do legado — EDEN não tem um módulo de upload de anexo genérico ainda.
2. `reseller_id` em vez de `reseller_registration_id` — EDEN modela revenda como uma tabela só.
3. Só existe a versão 2 (correta) da canonicalização do hash — a v1 do legado existe lá só para tolerar hashes já gravados antes de uma correção de bug real; EDEN nunca teve essa v1.
### Certificado de conclusão — server-side em vez de client-side
O legado desenha o certificado no navegador do backoffice (React → html2canvas → jsPDF) e recebe o PDF pronto via upload multipart em `finalizeEnvelope`. O EDEN já tem Chromium real server-side (construído para Documents/ANATEL) — `apps/api/src/signatures/certificate-template.ts` gera o HTML (conteúdo/texto legal portado verbatim, incluindo o aviso de que **não é certificação ICP-Brasil**) e renderiza no próprio servidor, eliminando o upload multipart inteiramente. Decisão deliberadamente limitada a essa troca técnica: os **dois passos autenticados** de finalização (`seal-checkpoint` depois `finalize`) foram mantidos exatamente como no legado — não virou automático mesmo sendo tecnicamente possível, porque esse checkpoint humano antes de uma finalização irreversível é comportamento de negócio, não um detalhe do desenho client-side do certificado.
### Fluxo (`apps/api/src/signatures/`)
`EnvelopeService` (criação — congela documentos com cópia S3 + SHA-256 recalculado, nunca confia cegamente no hash já gravado —, envio de convite, cancelamento, exclusão física só antes de SIGNED, verificação da cadeia), `PublicSignatureService` (fluxo do signatário só por token — nunca por `envelopeId`/`signerId` do corpo da requisição —, cada ação exige o estado anterior correto: confirmar leitura de cada documento → confirmar dados → aceitar termos → solicitar/validar OTP → assinar), `EnvelopeFinalizeService` (os dois passos), `PublicVerifyService` (só para `COMPLETED`, recomputa `envelope_hash` ao vivo e compara, nomes mascarados). Nota de design importante preservada do legado: o **envelope** salta direto de `SENT` para `SIGNED` quando o último signatário termina — o progresso fino (`VIEWED`..`OTP_VERIFIED`) vive inteiramente em `signature_signers.status`, nunca é espelhado passo a passo no envelope.
### Verificação
Suíte de testes nova e grande: `signature-hash-chain.spec.ts` (15 testes, puros, sem banco — canonicalização, determinismo do hash, máquina de estados, OTP, token) e `signature-envelope-flow.spec.ts` (13 testes, ciclo de vida completo via HTTP real: criação com signatário `contratada` automático, recusa sem representante legal, convite, os dois signatários passando pelo fluxo público inteiro incluindo código OTP errado/bloqueio/uso único, `SENT`→`SIGNED` no último signatário, cadeia de auditoria válida, `seal-checkpoint`, `finalize` com merge real via pdf-lib — confirmado contando páginas do PDF final —, `public-verify` com integridade válida e nomes mascarados, e proteção contra exclusão pós-assinatura). `renderPdf`/`uploadObject`/`getObjectStream`/`sendMail` mockados (S3 em memória); `mergePdfs`/`getPdfPageCount` NÃO mockados — o merge de PDF de verdade roda contra a saída (pequena, mas válida) do `renderPdf` mockado. Suíte completa: 70/70 verdes (42→70). `pnpm turbo run typecheck build --force` (28/28).
### Pendências
Rate limiting nas rotas públicas (por IP e por token, eden.md §6.8/§10.3) — lacuna conhecida, mesmo padrão de outros endpoints públicos deste projeto que também não têm ainda (ex. forgot-password). UI de frontend para todo o módulo (criação/envio/acompanhamento de envelope no backoffice, tela pública de assinatura do signatário, página pública de verificação) — não construída nesta leva, só o backend. `DECLINED` (recusa de assinatura) é um estado reservado no schema mas sem rota que o produza — mesma lacuna do legado, nunca foi implementado lá também.