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

@@ -0,0 +1,37 @@
import { createHash } from "node:crypto";
/**
* Canonical JSON — recursive deep key-sort (objects at every depth,
* arrays mapped element-wise), so a JSONB round-trip through Postgres
* (which does NOT preserve original key order — it reorders by key
* length then alphabetically) never changes the string a hash was
* computed over. Shared by audit_log (repositories/audit.ts) and the
* e-signature module's signature_audit_events (repositories/
* signatures.ts) — both hash-chained tables, same algorithm.
*
* The legacy OrçaFácil this was ported from carries a v1/v2 version
* split here: v1 only sorted top-level keys, which broke against
* Postgres's jsonb key reordering for anything nested (e.g. inside a
* `metadata` object) — a real bug discovered after already-hashed
* production events existed. EDEN has no pre-existing hash-chain rows
* from before this fix, so there is no v1 to port — every event this
* system ever writes uses this (the legacy's "v2") formula from day
* one. `hash_algorithm_version` is still recorded per-event (see the
* signature_audit_events schema) purely so a future algorithm change
* has the same version-aware escape hatch, not because v1 exists here.
*/
export function canonicalJson(value: unknown): string {
if (value === undefined) return "null";
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj).sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
}
/** `event_hash = SHA256(previousEventHash + canonicalJson(fields))` — the one formula every hash-chained table in this codebase uses. */
export function computeEventHash(previousEventHash: string, fields: Record<string, unknown>): string {
return createHash("sha256")
.update(previousEventHash + canonicalJson(fields))
.digest("hex");
}