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

@@ -7,3 +7,4 @@
export * from "./s3/client.js";
export * from "./smtp/mailer.js";
export * from "./pdf/renderer.js";
export * from "./pdf/merge.js";

View File

@@ -0,0 +1,32 @@
/**
* PDF merge — used by the e-signature module's finalization step (all
* frozen documents, in order, followed by the certificate) to produce
* one consolidated final PDF. Ported from the legacy's use of `pdf-lib`
* for the same purpose (eden.md §8.3).
*/
import { PDFDocument } from "pdf-lib";
export async function mergePdfs(buffers: Buffer[]): Promise<{ buffer: Buffer; pageCount: number }> {
const merged = await PDFDocument.create();
let pageCount = 0;
for (const buffer of buffers) {
const source = await PDFDocument.load(buffer);
const pages = await merged.copyPages(source, source.getPageIndices());
for (const page of pages) {
merged.addPage(page);
pageCount += 1;
}
}
const bytes = await merged.save();
return { buffer: Buffer.from(bytes), pageCount };
}
/** Page count of a single PDF buffer — used to record `signature_documents.page_count` when freezing a document. Returns `null` if the buffer isn't a valid PDF (never blocks freezing, matches eden.md §1.6). */
export async function getPdfPageCount(buffer: Buffer): Promise<number | null> {
try {
const doc = await PDFDocument.load(buffer);
return doc.getPageCount();
} catch {
return null;
}
}

View File

@@ -11,11 +11,18 @@
import nodemailer, { type Transporter } from "nodemailer";
import { loadEnv } from "@eden/config";
export interface SendMailAttachment {
filename: string;
content: Buffer;
contentType?: string;
}
export interface SendMailInput {
to: string;
subject: string;
html: string;
from?: string;
attachments?: SendMailAttachment[];
}
let transporter: Transporter | undefined;
@@ -55,6 +62,7 @@ export async function sendMail({
subject,
html,
from,
attachments,
}: SendMailInput): Promise<void> {
const t = getTransporter();
if (!t) {
@@ -69,6 +77,7 @@ export async function sendMail({
to,
subject,
html,
attachments,
});
} catch (err) {
console.error(`[mailer] falha ao enviar e-mail para ${to}:`, err);