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
38 lines
2.0 KiB
TypeScript
38 lines
2.0 KiB
TypeScript
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");
|
|
}
|