Files
eden/apps/api/test/signature-hash-chain.spec.ts
Matheus (Handix) 20b114a0bf 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
2026-09-04 13:44:43 -03:00

103 lines
4.0 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { canonicalJson, computeEventHash, signaturesRepo } from "@eden/database";
const { ENVELOPE_STATUSES, ENVELOPE_TRANSITIONS, assertEnvelopeTransition, InvalidEnvelopeTransitionError } =
signaturesRepo;
import { generateOtpCode, hashOtpCode } from "../src/signatures/otp.js";
import { generateSignerToken, hashSignerToken } from "../src/signatures/token.js";
describe("hash-chain canonicalization — pure functions, no DB", () => {
it("key order never changes the canonical string (defends against Postgres jsonb key reordering)", () => {
const a = canonicalJson({ b: 1, a: 2, metadata: { z: 1, y: 2 } });
const b = canonicalJson({ a: 2, b: 1, metadata: { y: 2, z: 1 } });
expect(a).toBe(b);
});
it("undefined values canonicalize to null, never dropped", () => {
expect(canonicalJson({ a: undefined })).toBe('{"a":null}');
});
it("computeEventHash chains — same inputs always produce the same hash (deterministic, reproducible verification)", () => {
const fields = { eventType: "TEST", metadata: { x: 1 } };
const h1 = computeEventHash("", fields);
const h2 = computeEventHash("", fields);
expect(h1).toBe(h2);
expect(h1).toMatch(/^[a-f0-9]{64}$/);
});
it("a different previous hash changes the result — this is what makes the chain tamper-evident", () => {
const fields = { eventType: "TEST" };
const h1 = computeEventHash("aaa", fields);
const h2 = computeEventHash("bbb", fields);
expect(h1).not.toBe(h2);
});
});
describe("envelope state machine — pure, no DB", () => {
it("every terminal status has no outgoing transitions", () => {
for (const terminal of ["COMPLETED", "DECLINED", "EXPIRED", "CANCELLED", "SUPERSEDED", "ERROR"] as const) {
expect(ENVELOPE_TRANSITIONS[terminal]).toEqual([]);
}
});
it("SENT -> SIGNED is a valid direct jump (per-signer granularity never mirrored on the envelope)", () => {
expect(() => assertEnvelopeTransition("SENT", "SIGNED")).not.toThrow();
});
it("rejects an invalid transition (e.g. DRAFT straight to SIGNED)", () => {
expect(() => assertEnvelopeTransition("DRAFT", "SIGNED")).toThrow(InvalidEnvelopeTransitionError);
});
it("rejects going backward out of a terminal state", () => {
expect(() => assertEnvelopeTransition("COMPLETED", "DRAFT")).toThrow(InvalidEnvelopeTransitionError);
});
it("every status in ENVELOPE_STATUSES has an entry in the transition table (no silently-forgotten state)", () => {
for (const status of ENVELOPE_STATUSES) {
expect(ENVELOPE_TRANSITIONS[status]).toBeDefined();
}
});
});
describe("OTP — pure crypto, no DB", () => {
it("generateOtpCode always returns 6 digits, zero-padded", () => {
for (let i = 0; i < 50; i++) {
const code = generateOtpCode();
expect(code).toMatch(/^\d{6}$/);
}
});
it("hashOtpCode is deterministic for the same code+secret, and never returns the plaintext code", () => {
const secret = "a".repeat(32);
const h1 = hashOtpCode("123456", secret);
const h2 = hashOtpCode("123456", secret);
expect(h1).toBe(h2);
expect(h1).not.toContain("123456");
expect(h1).toMatch(/^[a-f0-9]{64}$/);
});
it("a different secret produces a different hash for the same code (rainbow-table resistance depends on this)", () => {
const h1 = hashOtpCode("123456", "a".repeat(32));
const h2 = hashOtpCode("123456", "b".repeat(32));
expect(h1).not.toBe(h2);
});
});
describe("secure signer token — pure crypto, no DB", () => {
it("generateSignerToken returns 64 hex chars (32 bytes/256 bits)", () => {
const token = generateSignerToken();
expect(token).toMatch(/^[a-f0-9]{64}$/);
});
it("two generated tokens are never equal", () => {
expect(generateSignerToken()).not.toBe(generateSignerToken());
});
it("hashSignerToken is deterministic and never returns the plaintext token", () => {
const token = generateSignerToken();
const hash = hashSignerToken(token);
expect(hash).not.toBe(token);
expect(hashSignerToken(token)).toBe(hash);
});
});