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:
102
apps/api/test/signature-hash-chain.spec.ts
Normal file
102
apps/api/test/signature-hash-chain.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user