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

@@ -20,6 +20,7 @@ import { HrTimeclockModule } from "./hr-timeclock/hr-timeclock.module.js";
import { FiscalModule } from "./fiscal/fiscal.module.js";
import { BillingModule } from "./billing/billing.module.js";
import { DocumentsModule } from "./documents/documents.module.js";
import { SignaturesModule } from "./signatures/signatures.module.js";
@Module({
imports: [
@@ -43,6 +44,7 @@ import { DocumentsModule } from "./documents/documents.module.js";
FiscalModule,
BillingModule,
DocumentsModule,
SignaturesModule,
],
controllers: [HealthController],
})

View File

@@ -0,0 +1,221 @@
/**
* Certificado de conclusão — eden.md §8.5. Conteúdo (estrutura, rótulos,
* texto legal) portado verbatim de SignatureCertificatePDFTemplate.jsx do
* legado — só o texto do rodapé legal (MP 2.200-2/2001, Lei 14.063/2020,
* declaração de não ser ICP-Brasil) e o bloco "Manifestação de Vontade"
* são conteúdo jurídico, extraídos ao pé da letra, nunca parafraseados.
*
* Diferença deliberada do legado: renderizado inteiramente no servidor
* via Chromium (packages/integrations/src/pdf/renderer.ts), não
* desenhado no navegador do backoffice via html2canvas+jsPDF — o EDEN já
* tem esse motor (construído nesta mesma sessão para Documents/ANATEL) e
* gerar aqui elimina o upload multipart do "certificado pronto" que o
* legado precisa aceitar como entrada confiável em finalizeEnvelope.
*/
import QRCode from "qrcode";
import { formatCNPJ, formatCPF } from "@eden/domain-shared";
import type { SignatureAuditEvent, SignatureDocument, SignatureEnvelope, SignatureSigner } from "@eden/database";
const PARTY_LABELS: Record<string, string> = {
contratada: "Contratada",
contratante: "Contratante",
};
const TIMELINE_EVENTS: Array<{ type: string; label: string }> = [
{ type: "INVITE_SENT", label: "Convite enviado" },
{ type: "LINK_OPENED", label: "Link acessado" },
{ type: "CUSTOMER_DATA_CONFIRMED", label: "Dados confirmados" },
{ type: "ELECTRONIC_SIGNATURE_TERMS_ACCEPTED", label: "Termo de assinatura eletrônica aceito" },
{ type: "OTP_VERIFIED", label: "Código de verificação validado" },
{ type: "SIGNATURE_COMMITTED", label: '"Assinar e Concordar" acionado' },
];
function fmtDateTime(date: Date | string | null): string {
if (!date) return "—";
const d = typeof date === "string" ? new Date(date) : date;
return new Intl.DateTimeFormat("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZone: "UTC",
})
.format(d)
.replace(",", " às");
}
function maskEmail(email: string | null): string {
if (!email) return "—";
const [user, domain] = email.split("@");
if (!user || !domain) return email;
return `${user.slice(0, 2)}${"*".repeat(Math.max(user.length - 2, 1))}@${domain}`;
}
function signerTimeline(auditEvents: SignatureAuditEvent[], signerId: string): Array<{ label: string; timestamp: Date }> {
const result: Array<{ label: string; timestamp: Date }> = [];
for (const te of TIMELINE_EVENTS) {
const event = auditEvents.find((e) => e.signer_id === signerId && e.event_type === te.type);
if (event) result.push({ label: te.label, timestamp: event.timestamp_utc });
}
return result;
}
function documentPageRanges(documents: SignatureDocument[]): Array<SignatureDocument & { pageRange: string }> {
let acc = 1;
return documents.map((doc) => {
const pages = doc.page_count ?? 0;
const range = pages > 0 ? `${acc}-${acc + pages - 1}` : "—";
acc += pages;
return { ...doc, pageRange: range };
});
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export interface CertificateInput {
envelope: SignatureEnvelope;
documents: SignatureDocument[];
signers: SignatureSigner[];
auditEvents: SignatureAuditEvent[];
verifyUrl: string;
}
export async function buildCertificateHtml(input: CertificateInput): Promise<string> {
const { envelope, documents, signers, auditEvents, verifyUrl } = input;
const today = new Date();
const rangedDocs = documentPageRanges(documents);
const clientLabel = signers.find((s) => s.party === "contratante")?.full_name ?? "—";
const qrDataUrl = await QRCode.toDataURL(verifyUrl, { margin: 1, width: 200 });
const documentRows = rangedDocs
.map(
(doc) => `
<tr>
<td>${escapeHtml(doc.title)}</td>
<td class="center">v${doc.document_version}</td>
<td class="center">${doc.pageRange}</td>
<td class="mono">${doc.original_sha256}</td>
</tr>`,
)
.join("");
const signerBlocks = signers
.map((signer) => {
const doc = signer.person_type === "pj" ? formatCNPJ(signer.cpf ?? "") : formatCPF(signer.cpf ?? "");
const timeline = signerTimeline(auditEvents, signer.id)
.map((t, i) => `${i > 0 ? " · " : ""}${escapeHtml(t.label)} (${fmtDateTime(t.timestamp)})`)
.join("");
const companyRow = signer.company_name
? `<tr><td>Empresa: ${escapeHtml(signer.company_name)}</td><td>${signer.role_title ? `Cargo: ${escapeHtml(signer.role_title)}` : ""}</td></tr>`
: "";
return `
<table class="cert-table signer-block">
<tbody>
<tr><td colspan="2" class="section-header">SIGNATÁRIO — ${escapeHtml(signer.full_name)} (${PARTY_LABELS[signer.party] ?? signer.party})</td></tr>
<tr><td>CPF/CNPJ: ${doc}</td><td>E-mail: ${maskEmail(signer.email)}</td></tr>
${companyRow}
<tr><td>Status da autenticação: ${signer.status === "SIGNED" ? "VALIDADA" : signer.status}</td><td>Assinado em: ${fmtDateTime(signer.signed_at)}</td></tr>
<tr><td colspan="2"><strong>Cronologia:</strong> ${timeline}</td></tr>
</tbody>
</table>`;
})
.join("");
return `
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>${css()}</style>
</head>
<body>
<div class="page">
<div class="header">
<div class="brand">Handix do Brasil</div>
<img class="qr" src="${qrDataUrl}" alt="QR Code de verificação" />
</div>
<h1>CERTIFICADO DE AUTENTICIDADE E ASSINATURA ELETRÔNICA</h1>
<p class="subtitle">Envelope ${escapeHtml(envelope.envelope_number)} · Emitido em ${fmtDateTime(today)}</p>
<table class="cert-table">
<tbody>
<tr><td colspan="2" class="section-header center">IDENTIFICAÇÃO DA TRANSAÇÃO</td></tr>
<tr><td>Número do envelope: ${escapeHtml(envelope.envelope_number)}</td><td>Status: ASSINADO E ÍNTEGRO</td></tr>
<tr><td>Cliente: ${escapeHtml(clientLabel)}</td><td>Documentos: ${documents.length}</td></tr>
<tr><td>Assinatura concluída em: ${fmtDateTime(envelope.signed_at)}</td><td>Tipo: Assinatura Eletrônica</td></tr>
<tr><td colspan="2">Método de autenticação: Código de uso único (OTP) enviado por e-mail</td></tr>
</tbody>
</table>
<table class="cert-table">
<thead>
<tr><th colspan="4" class="section-header center">DOCUMENTOS INTEGRANTES</th></tr>
<tr class="subhead"><th>Documento</th><th>Versão</th><th>Páginas</th><th>SHA-256</th></tr>
</thead>
<tbody>${documentRows}</tbody>
</table>
<p class="hint">Algoritmo de hash: SHA-256, calculado sobre cada documento congelado no momento da criação do envelope.</p>
${signerBlocks}
<table class="cert-table">
<tbody>
<tr><td class="section-header">MANIFESTAÇÃO DE VONTADE</td></tr>
<tr><td>O(s) signatário(s) confirmaram os dados apresentados, tiveram acesso aos documentos integrantes deste envelope (com confirmação individual de leitura por documento), concordaram com a utilização do meio eletrônico, validaram o código de uso único enviado ao endereço de e-mail cadastrado e realizaram ação expressa no comando "ASSINAR E CONCORDAR".</td></tr>
</tbody>
</table>
<table class="cert-table">
<tbody>
<tr><td colspan="2" class="section-header">INTEGRIDADE E AUTENTICIDADE</td></tr>
<tr><td>Documentos: ÍNTEGROS</td><td>Autenticação: VALIDADA</td></tr>
<tr><td>Manifestação de vontade: REGISTRADA</td><td>Evidence Checkpoint: VÁLIDO</td></tr>
<tr><td colspan="2" class="mono">Hash do envelope (documentos + signatários): ${envelope.envelope_hash ?? "—"}</td></tr>
<tr><td colspan="2" class="mono">Identificador do checkpoint: ${envelope.evidence_checkpoint_hash ?? "—"}</td></tr>
<tr><td colspan="2" class="mono">Verification ID: ${envelope.verification_id ?? "—"}</td></tr>
<tr><td colspan="2">Verificação pública: ${escapeHtml(verifyUrl)}</td></tr>
</tbody>
</table>
<div class="legal-footer">
Este certificado foi gerado eletronicamente pela plataforma da Handix do Brasil, registrando as evidências
técnicas do processo de assinatura eletrônica descrito acima (identidade utilizada, autenticação, manifestação
de vontade, integridade documental e rastreabilidade), nos termos do art. 10, §2º, da Medida Provisória
nº 2.200-2/2001 e da Lei nº 14.063/2020. Este documento não constitui certificação digital ICP-Brasil nem
carimbo do tempo de autoridade externa — a Handix não possui, no momento da emissão, esse tipo de integração.
</div>
</div>
</body>
</html>`;
}
function css(): string {
return `
@page { size: A4 portrait; margin: 15mm; }
* { box-sizing: border-box; }
body { font-family: Arial, Helvetica, sans-serif; color: #111827; font-size: 9.5px; margin: 0; }
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.brand { font-size: 16px; font-weight: 700; color: #0F766E; }
.qr { height: 72px; width: 72px; }
h1 { font-size: 14px; font-weight: 700; text-align: center; margin: 0 0 4px; }
.subtitle { font-size: 10px; text-align: center; color: #6b7280; margin: 0 0 16px; }
.cert-table { width: 100%; border-collapse: collapse; margin-bottom: 14px; }
.cert-table td, .cert-table th { border: 1px solid #e5e7eb; padding: 5px 8px; font-size: 9.5px; vertical-align: top; text-align: left; }
.section-header { font-weight: 700; background: #f3f4f6; }
.subhead th { font-weight: 700; background: #f9fafb; }
.center { text-align: center; }
.mono { font-family: monospace; font-size: 8px; word-break: break-all; }
.hint { font-size: 8px; color: #9ca3af; margin: -8px 0 14px; }
.signer-block { page-break-inside: avoid; }
.legal-footer { font-size: 8px; color: #9ca3af; line-height: 1.5; border-top: 1px solid #e5e7eb; padding-top: 8px; }
`;
}

View File

@@ -0,0 +1,5 @@
import { IsOptional, IsString } from "class-validator";
export class CancelEnvelopeDto {
@IsOptional() @IsString() reason?: string;
}

View File

@@ -0,0 +1,51 @@
import { Type } from "class-transformer";
import {
ArrayMinSize,
IsArray,
IsEmail,
IsIn,
IsOptional,
IsString,
IsUUID,
MinLength,
ValidateNested,
} from "class-validator";
export class SignerInputDto {
@IsOptional() @IsIn(["pf", "pj"]) personType?: "pf" | "pj";
@IsString() @MinLength(1) fullName!: string;
@IsOptional() @IsString() cpf?: string;
@IsEmail() email!: string;
@IsOptional() @IsString() phone?: string;
@IsOptional() @IsString() companyName?: string;
@IsOptional() @IsString() cnpj?: string;
@IsOptional() @IsString() roleTitle?: string;
}
/**
* The `contratada` signer is never in this array — it's always
* auto-populated server-side from `companies.legal_rep_*` (eden.md
* §5.3/§5.4). This DTO only carries the `contratante` signer(s).
*/
export class CreateEnvelopeDto {
@IsUUID() companyId!: string;
@IsOptional() @IsUUID() clientRegistrationId?: string;
@IsOptional() @IsUUID() resellerId?: string;
/** `document_generations.id` — already-generated, S3-backed PDFs to freeze into this envelope (see the migration's header comment for why EDEN sources from here instead of a generic attachment table). */
@IsArray()
@ArrayMinSize(1)
@IsUUID("4", { each: true })
documentGenerationIds!: string[];
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => SignerInputDto)
signers!: SignerInputDto[];
}

View File

@@ -0,0 +1,6 @@
import { IsIn } from "class-validator";
export class SendInviteDto {
@IsIn(["contratada", "contratante", "both"])
targets!: "contratada" | "contratante" | "both";
}

View File

@@ -0,0 +1,5 @@
import { IsString, Length } from "class-validator";
export class VerifyOtpDto {
@IsString() @Length(6, 6) code!: string;
}

View File

@@ -0,0 +1,30 @@
import { createHash } from "node:crypto";
import type { EnvelopeWithDetails } from "@eden/database";
/**
* eden.md §8.4 — SHA-256 of the envelope's logical facts (never the PDF
* bytes): plain `JSON.stringify` (not the recursive-sort canonicalJson
* used by the hash-chain) is safe here because this object is built
* fresh in JS every time, with the same key order both when sealing and
* when re-verifying — it never round-trips through a jsonb column, which
* is the only thing that made canonicalization necessary elsewhere.
* documents/signers always read pre-sorted by sort_order (a stable
* column), so the result is deterministic and independently
* recomputable — used both by EnvelopeFinalizeService (to seal it) and
* PublicVerifyService (to prove nothing changed since).
*/
export function computeEnvelopeHash(envelope: EnvelopeWithDetails, evidenceCheckpointHash: string): string {
const canonical = {
envelope_number: envelope.envelope_number,
evidence_checkpoint_hash: evidenceCheckpointHash,
documents: envelope.documents
.slice()
.sort((a, b) => a.sort_order - b.sort_order)
.map((d) => ({ title: d.title, version: d.document_version, sha256: d.original_sha256 })),
signers: envelope.signers
.slice()
.sort((a, b) => a.sort_order - b.sort_order)
.map((s) => ({ party: s.party, full_name: s.full_name, cpf: s.cpf, email: s.email, status: s.status })),
};
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
}

View File

@@ -0,0 +1,234 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Inject,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { createHash, randomUUID } from "node:crypto";
import {
companiesRepo,
documentsRepo,
signaturesRepo,
type ActorType,
type EnvelopeWithDetails,
type SignatureEnvelope,
} from "@eden/database";
import { getObjectStream, getPdfPageCount, sendMail, uploadObject } from "@eden/integrations";
import type { EdenEnv } from "@eden/config";
import { EDEN_ENV } from "../config/config.constants.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
import { maxGrantedScope } from "../auth/permissions.js";
import { generateSignerToken, hashSignerToken, SIGNER_TOKEN_TTL_MS } from "./token.js";
import type { CreateEnvelopeDto } from "./dto/create-envelope.dto.js";
import type { SendInviteDto } from "./dto/send-invite.dto.js";
import type { CancelEnvelopeDto } from "./dto/cancel-envelope.dto.js";
const { EnvelopeNotFoundError, InvalidEnvelopeTransitionError } = signaturesRepo;
export interface RequestContext {
ipAddress: string | null;
userAgent: string | null;
}
function resolveActorType(roleKey: string): ActorType {
if (roleKey === "super_admin") return "SUPER_ADMIN";
if (roleKey === "admin") return "ADMIN";
return "BACKOFFICE";
}
async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
@Injectable()
export class EnvelopeService {
constructor(@Inject(EDEN_ENV) private readonly env: EdenEnv) {}
async list(user: EdenAuthenticatedUser) {
const scope = maxGrantedScope(user.permissions, "signatures", "view");
if (!scope) throw new ForbiddenException("Sem permissão para 'view' em 'signatures'");
if (scope === "all") return signaturesRepo.listEnvelopes({ scope: "all" });
return signaturesRepo.listEnvelopes({ scope: "reseller", resellerId: user.resellerId });
}
async get(id: string, user: EdenAuthenticatedUser): Promise<EnvelopeWithDetails> {
const scope = maxGrantedScope(user.permissions, "signatures", "view");
const envelope = await signaturesRepo.getEnvelopeById(id);
if (!envelope || !scope) throw new NotFoundException("Envelope de assinatura não encontrado");
if (scope !== "all" && envelope.reseller_id !== user.resellerId) {
throw new NotFoundException("Envelope de assinatura não encontrado");
}
return envelope;
}
/**
* Transação única (eden.md §5.4): resolve a empresa emissora e recusa
* se ela não tiver representante legal cadastrado, congela cada
* document_generations indicado (cópia S3 própria + SHA-256
* recalculado — nunca confia cegamente no pdf_sha256 já gravado, ainda
* que ele já devesse bater), insere os signatários (contratada
* automático + contratante(s) do formulário), e transiciona
* DRAFT->READY.
*/
async create(dto: CreateEnvelopeDto, user: EdenAuthenticatedUser): Promise<EnvelopeWithDetails> {
const scope = maxGrantedScope(user.permissions, "signatures", "edit");
if (!scope) throw new ForbiddenException("Sem permissão para 'edit' em 'signatures'");
const hasClient = dto.clientRegistrationId != null;
const hasReseller = dto.resellerId != null;
if (hasClient === hasReseller) {
throw new BadRequestException("Informe exatamente uma origem: clientRegistrationId OU resellerId");
}
const company = await companiesRepo.getCompanyById(dto.companyId);
if (!company) throw new NotFoundException("Empresa não encontrada");
if (!company.legal_rep_name || !company.legal_rep_cpf || !company.legal_rep_email) {
throw new BadRequestException(
"Esta empresa não tem representante legal cadastrado (nome/CPF/e-mail) — cadastre antes de criar um envelope de assinatura.",
);
}
const documents = [];
for (const generationId of dto.documentGenerationIds) {
const generation = await documentsRepo.getGenerationById(generationId);
if (!generation) throw new BadRequestException(`Geração de documento ${generationId} não encontrada`);
if (generation.status !== "final" || !generation.pdf_s3_key || !generation.pdf_sha256) {
throw new BadRequestException(`Geração de documento ${generationId} ainda não tem um PDF disponível`);
}
const buffer = await streamToBuffer(await getObjectStream(generation.pdf_s3_key));
const sha256 = createHash("sha256").update(buffer).digest("hex");
const frozenKey = `signatures/frozen/${randomUUID()}.pdf`;
await uploadObject(frozenKey, buffer, "application/pdf");
const pageCount = await getPdfPageCount(buffer);
const template = await documentsRepo.getTemplateById(generation.template_id);
documents.push({
sourceGenerationId: generation.id,
title: template?.name ?? `Documento ${documents.length + 1}`,
sourceMimeType: "application/pdf",
sourceS3Key: frozenKey,
originalSha256: sha256,
pageCount,
});
}
const signers = [
{
party: "contratada" as const,
fullName: company.legal_rep_name,
cpf: company.legal_rep_cpf,
email: company.legal_rep_email,
roleTitle: company.legal_rep_role,
},
...dto.signers.map((s) => ({
party: "contratante" as const,
personType: s.personType ?? null,
fullName: s.fullName,
cpf: s.cpf ?? null,
email: s.email,
phone: s.phone ?? null,
companyName: s.companyName ?? null,
cnpj: s.cnpj ?? null,
roleTitle: s.roleTitle ?? null,
})),
];
const envelope = await signaturesRepo.createEnvelope(
{
companyId: dto.companyId,
clientRegistrationId: dto.clientRegistrationId ?? null,
resellerId: dto.resellerId ?? null,
createdBy: user.id,
documents,
signers,
},
{
actorType: resolveActorType(user.roleKey),
actorId: user.id,
actorRole: user.roleKey,
ipAddress: null,
userAgent: null,
},
);
return envelope;
}
/** eden.md §5.5: gera um token novo por signatário-alvo ainda não assinado (revoga implicitamente o anterior), 7 dias de validade, e-mail com o link. Envelope READY->SENT na primeira vez. */
async sendInvite(id: string, dto: SendInviteDto, user: EdenAuthenticatedUser): Promise<EnvelopeWithDetails> {
const envelope = await this.get(id, user);
const actor = { actorType: resolveActorType(user.roleKey), actorId: user.id, actorRole: user.roleKey };
const targetParties = dto.targets === "both" ? ["contratada", "contratante"] : [dto.targets];
const targets = envelope.signers.filter((s) => targetParties.includes(s.party) && s.status !== "SIGNED");
for (const signer of targets) {
const plaintextToken = generateSignerToken();
const tokenHash = hashSignerToken(plaintextToken);
const expiresAt = new Date(Date.now() + SIGNER_TOKEN_TTL_MS);
await signaturesRepo.setSignerToken(signer.id, tokenHash, expiresAt);
if (signer.status === "PENDING") {
await signaturesRepo.updateSignerStatus(signer.id, "LINK_SENT", ["PENDING"]);
}
const signUrl = `${this.env.PUBLIC_BASE_URL ?? "http://localhost:3001"}/assinar/${plaintextToken}`;
await sendMail({
to: signer.email,
subject: `Handix — Assinatura eletrônica pendente (${envelope.envelope_number})`,
html: `<p>Olá, ${signer.full_name}.</p><p>Você tem um documento aguardando assinatura eletrônica.</p><p><a href="${signUrl}">Acessar e assinar</a></p><p>Este link expira em 7 dias.</p>`,
});
await signaturesRepo.recordEvent({
envelopeId: envelope.id,
signerId: signer.id,
eventType: "INVITE_SENT",
...actor,
});
}
if (envelope.status === "READY") {
await signaturesRepo.updateEnvelopeStatus(id, "SENT");
}
return this.get(id, user);
}
async cancel(id: string, dto: CancelEnvelopeDto, user: EdenAuthenticatedUser): Promise<SignatureEnvelope> {
await this.get(id, user);
try {
return await signaturesRepo.cancelEnvelope(id, dto.reason ?? null);
} catch (err) {
if (err instanceof EnvelopeNotFoundError) throw new NotFoundException("Envelope de assinatura não encontrado");
if (err instanceof InvalidEnvelopeTransitionError) throw new ConflictException(err.message);
throw err;
}
}
/** Exclusão física — só permitida antes de SIGNED (eden.md §1.9). Depois disso, só cancelamento (dados preservados). */
async delete(id: string, user: EdenAuthenticatedUser): Promise<void> {
const envelope = await this.get(id, user);
const irreversibleStates = ["SIGNED", "FINALIZING", "COMPLETED"];
if (irreversibleStates.includes(envelope.status)) {
throw new ConflictException(
"Este envelope já tem manifestação de vontade registrada — só é possível cancelar, nunca excluir.",
);
}
await signaturesRepo.deleteEnvelope(id);
}
async verifyAudit(id: string, user: EdenAuthenticatedUser) {
await this.get(id, user);
return signaturesRepo.verifyChain(id);
}
async listAuditEvents(id: string, user: EdenAuthenticatedUser) {
await this.get(id, user);
return signaturesRepo.listAuditEvents(id);
}
}

View File

@@ -0,0 +1,155 @@
import { createHash, randomBytes } from "node:crypto";
import { ConflictException, ForbiddenException, Inject, Injectable, NotFoundException } from "@nestjs/common";
import { signaturesRepo, type EnvelopeWithDetails, type SignatureEnvelope } from "@eden/database";
import { getObjectStream, mergePdfs, renderPdf, sendMail, uploadObject } from "@eden/integrations";
import type { EdenEnv } from "@eden/config";
import { EDEN_ENV } from "../config/config.constants.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
import { maxGrantedScope } from "../auth/permissions.js";
import { buildCertificateHtml } from "./certificate-template.js";
import { computeEnvelopeHash } from "./envelope-hash.js";
const { EnvelopeNotFoundError, InvalidEnvelopeTransitionError } = signaturesRepo;
async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
/**
* Finalização em dois passos deliberados (eden.md §8.1) — verification_id
* e evidence_checkpoint_hash precisam existir ANTES do certificado ser
* desenhado, porque são impressos nele (inclusive como QR code). Mesmo o
* EDEN gerando o certificado no servidor (não precisa mais do navegador
* de ninguém), os dois passos continuam sendo ações explícitas e
* autenticadas do backoffice — ver o doc-comment em
* PublicSignatureService.commitSignature para o porquê de não ter virado
* automático.
*/
@Injectable()
export class EnvelopeFinalizeService {
constructor(@Inject(EDEN_ENV) private readonly env: EdenEnv) {}
private async getScoped(id: string, user: EdenAuthenticatedUser): Promise<EnvelopeWithDetails> {
const scope = maxGrantedScope(user.permissions, "signatures", "edit");
if (!scope) throw new ForbiddenException("Sem permissão para 'edit' em 'signatures'");
const envelope = await signaturesRepo.getEnvelopeById(id);
if (!envelope) throw new NotFoundException("Envelope de assinatura não encontrado");
if (scope !== "all" && envelope.reseller_id !== user.resellerId) {
throw new NotFoundException("Envelope de assinatura não encontrado");
}
return envelope;
}
/**
* Passo 1 — pré-condição status === 'SIGNED'. Computa evidence_checkpoint_hash
* (tail da cadeia de auditoria neste momento) e envelope_hash (eden.md
* §8.4 — SHA-256 dos fatos lógicos: envelope_number + checkpoint hash +
* documentos/signatários ordenados por sort_order), reserva
* verification_id, e transiciona SIGNED->FINALIZING.
*/
async sealCheckpoint(id: string, user: EdenAuthenticatedUser): Promise<SignatureEnvelope> {
const envelope = await this.getScoped(id, user);
if (envelope.status !== "SIGNED") {
throw new ConflictException(
`Envelope precisa estar SIGNED para selar o checkpoint (status atual: ${envelope.status})`,
);
}
const evidenceCheckpointHash = await signaturesRepo.peekChainTail(id);
const verificationId = randomBytes(16).toString("hex");
const envelopeHash = computeEnvelopeHash(envelope, evidenceCheckpointHash);
let sealed: SignatureEnvelope;
try {
sealed = await signaturesRepo.sealEvidenceCheckpoint(id, verificationId, evidenceCheckpointHash, envelopeHash);
} catch (err) {
if (err instanceof EnvelopeNotFoundError) throw new NotFoundException("Envelope de assinatura não encontrado");
if (err instanceof InvalidEnvelopeTransitionError) throw new ConflictException(err.message);
throw err;
}
const actor = {
actorType: "BACKOFFICE" as const,
actorId: user.id,
actorRole: user.roleKey,
};
await signaturesRepo.recordEvent({ envelopeId: id, eventType: "EVIDENCE_CHECKPOINT_SEALED", ...actor });
await signaturesRepo.recordEvent({ envelopeId: id, eventType: "ENVELOPE_HASH_CALCULATED", ...actor });
return sealed;
}
/**
* Passo 2 — pré-condição status === 'FINALIZING'. Renderiza o
* certificado no servidor (Chromium — diferença deliberada do legado,
* ver o header comment de certificate-template.ts), consolida com os
* documentos congelados via pdf-lib (ordem: documentos por sort_order,
* certificado por último), calcula o SHA-256 final, arquiva, e
* transiciona FINALIZING->COMPLETED. O e-mail final (best-effort, fora
* do "commit" lógico — uma falha de envio nunca desfaz a conclusão) é
* o único passo que roda depois de tudo já persistido.
*/
async finalize(id: string, user: EdenAuthenticatedUser): Promise<SignatureEnvelope> {
const envelope = await this.getScoped(id, user);
if (envelope.status !== "FINALIZING") {
throw new ConflictException(
`Envelope precisa estar FINALIZING para finalizar (status atual: ${envelope.status})`,
);
}
const actor = { actorType: "BACKOFFICE" as const, actorId: user.id, actorRole: user.roleKey };
const auditEvents = await signaturesRepo.listAuditEvents(id);
const verifyUrl = `${this.env.PUBLIC_BASE_URL ?? "http://localhost:3001"}/verificar/${envelope.verification_id}`;
const certificateHtml = await buildCertificateHtml({
envelope,
documents: envelope.documents,
signers: envelope.signers,
auditEvents,
verifyUrl,
});
const certificateBuffer = await renderPdf({ html: certificateHtml });
const certificateS3Key = `signatures/${id}/certificate.pdf`;
await uploadObject(certificateS3Key, certificateBuffer, "application/pdf");
await signaturesRepo.recordEvent({ envelopeId: id, eventType: "CERTIFICATE_GENERATED", ...actor });
const documentBuffers = await Promise.all(
envelope.documents
.slice()
.sort((a, b) => a.sort_order - b.sort_order)
.map(async (doc) => streamToBuffer(await getObjectStream(doc.source_s3_key))),
);
const { buffer: mergedBuffer, pageCount } = await mergePdfs([...documentBuffers, certificateBuffer]);
const finalPdfSha256 = createHash("sha256").update(mergedBuffer).digest("hex");
const finalS3Key = `signatures/${id}/final.pdf`;
await uploadObject(finalS3Key, mergedBuffer, "application/pdf");
await signaturesRepo.recordEvent({
envelopeId: id,
eventType: "CONSOLIDATED_PDF_GENERATED",
...actor,
metadata: { pageCount },
});
await signaturesRepo.recordEvent({ envelopeId: id, eventType: "FINAL_PDF_HASH_CALCULATED", ...actor });
await signaturesRepo.setFinalizationFiles(id, { certificateS3Key, finalS3Key, finalPdfSha256 });
const completed = await signaturesRepo.updateEnvelopeStatus(id, "COMPLETED");
await signaturesRepo.recordEvent({ envelopeId: id, eventType: "ENVELOPE_COMPLETED", ...actor });
for (const signer of envelope.signers.filter((s) => s.party === "contratante")) {
await sendMail({
to: signer.email,
subject: `Handix — Documento assinado (${envelope.envelope_number})`,
html: `<p>Olá, ${signer.full_name}.</p><p>Seu documento foi assinado eletronicamente por todas as partes. Segue em anexo o PDF final com certificado de autenticidade.</p><p>Verificação pública: <a href="${verifyUrl}">${verifyUrl}</a></p>`,
attachments: [{ filename: `${envelope.envelope_number}.pdf`, content: mergedBuffer, contentType: "application/pdf" }],
});
}
await signaturesRepo.recordEvent({ envelopeId: id, eventType: "FINAL_DOCUMENT_EMAILED", ...actor });
return completed;
}
}

View File

@@ -0,0 +1,24 @@
import { createHmac, randomInt } from "node:crypto";
/**
* OTP algorithm — eden.md §6, fork-verified against the legacy's exact
* `otpService.js`. This is the single most security-sensitive piece of
* this module; do not "simplify" without re-reading that section.
*/
export const OTP_TTL_MS = 5 * 60 * 1000;
export const OTP_DEFAULT_MAX_ATTEMPTS = 5;
/** 6 digits, `0` to `999999`, zero-padded — `crypto.randomInt`, never `Math.random()`. */
export function generateOtpCode(): string {
return String(randomInt(0, 1000000)).padStart(6, "0");
}
/**
* HMAC-SHA256 with a server secret — deliberately NOT a plain SHA-256:
* the code space is only 10^6 values, trivially rainbow-tableable
* without a secret. The plaintext code is never persisted, logged, or
* returned by any API response — only emailed.
*/
export function hashOtpCode(code: string, secret: string): string {
return createHmac("sha256", secret).update(code).digest("hex");
}

View File

@@ -0,0 +1,61 @@
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
import { ApiTags } from "@nestjs/swagger";
import type { Request } from "express";
import { PublicSignatureService } from "./public-signature.service.js";
import { VerifyOtpDto } from "./dto/verify-otp.dto.js";
function requestContext(req: Request) {
return {
ipAddress: req.ip ?? null,
userAgent: req.headers["user-agent"] ?? null,
};
}
/**
* Fluxo público de assinatura — eden.md §10. Sem autenticação de sessão:
* autorização é inteiramente pelo token do link (`:token`), nunca por
* `envelopeId`/`signerId` de corpo de requisição. Rate limiting (por IP e
* por token, eden.md §10.3/§6.8) é uma lacuna conhecida — ver
* docs/progress.md, mesmo padrão de outros endpoints públicos deste
* projeto (ex. forgot-password) que também não têm rate limit ainda.
*/
@ApiTags("public-signature")
@Controller("v1/public-signature")
export class PublicSignatureController {
constructor(private readonly publicSignature: PublicSignatureService) {}
@Get(":token")
getState(@Param("token") token: string) {
return this.publicSignature.getPublicState(token);
}
@Post(":token/documents/:documentId/confirm-read")
confirmDocumentRead(@Param("token") token: string, @Param("documentId") documentId: string, @Req() req: Request) {
return this.publicSignature.confirmDocumentRead(token, documentId, requestContext(req));
}
@Post(":token/confirm-data")
confirmData(@Param("token") token: string, @Req() req: Request) {
return this.publicSignature.confirmData(token, requestContext(req));
}
@Post(":token/consent")
consent(@Param("token") token: string, @Req() req: Request) {
return this.publicSignature.acceptConsent(token, requestContext(req));
}
@Post(":token/otp/request")
requestOtp(@Param("token") token: string, @Req() req: Request) {
return this.publicSignature.requestOtp(token, requestContext(req));
}
@Post(":token/otp/verify")
verifyOtp(@Param("token") token: string, @Body() dto: VerifyOtpDto, @Req() req: Request) {
return this.publicSignature.verifyOtp(token, dto.code, requestContext(req));
}
@Post(":token/sign")
sign(@Param("token") token: string, @Req() req: Request) {
return this.publicSignature.commitSignature(token, requestContext(req));
}
}

View File

@@ -0,0 +1,268 @@
import { BadRequestException, ConflictException, GoneException, Inject, Injectable, NotFoundException } from "@nestjs/common";
import { signaturesRepo, type EnvelopeWithDetails, type SignatureSigner } from "@eden/database";
import { sendMail } from "@eden/integrations";
import type { EdenEnv } from "@eden/config";
import { EDEN_ENV } from "../config/config.constants.js";
import { hashSignerToken } from "./token.js";
import { generateOtpCode, hashOtpCode, OTP_DEFAULT_MAX_ATTEMPTS, OTP_TTL_MS } from "./otp.js";
import { parseUserAgent } from "./user-agent.js";
export interface PublicRequestContext {
ipAddress: string | null;
userAgent: string | null;
}
/**
* Rota pública de assinatura — eden.md §10. Autorização SOMENTE pelo
* token do link: `envelope_id`/`signer_id` nunca são aceitos do corpo da
* requisição, tudo deriva do signatário que `resolveSigner` resolveu a
* partir do hash do token. Cada ação sensível exige o estado anterior
* correto no banco — não é possível pular etapas manipulando chamadas de
* API diretamente.
*/
@Injectable()
export class PublicSignatureService {
constructor(@Inject(EDEN_ENV) private readonly env: EdenEnv) {}
private getOtpSecret(): string {
if (!this.env.SIGNATURE_OTP_SECRET) {
throw new Error("SIGNATURE_OTP_SECRET não configurado — defina essa variável de ambiente antes de usar OTP.");
}
return this.env.SIGNATURE_OTP_SECRET;
}
/** Nunca busca pelo token em claro — só pelo SHA-256 (eden.md §10.1). */
async resolveSigner(token: string): Promise<{ signer: SignatureSigner; envelope: EnvelopeWithDetails }> {
const tokenHash = hashSignerToken(token);
const signer = await signaturesRepo.getSignerByTokenHash(tokenHash);
if (!signer) throw new NotFoundException("Link inválido.");
if (signer.secure_token_revoked_at) throw new GoneException("Este link foi revogado.");
if (signer.secure_token_expires_at && signer.secure_token_expires_at.getTime() < Date.now()) {
throw new GoneException("Este link expirou — solicite um novo convite.");
}
const envelope = await signaturesRepo.getEnvelopeById(signer.envelope_id);
if (!envelope) throw new NotFoundException("Link inválido.");
if (envelope.status === "CANCELLED" || envelope.status === "EXPIRED") {
throw new GoneException("Este envelope não está mais disponível para assinatura.");
}
await signaturesRepo.touchSignerTokenUsage(signer.id);
return { signer, envelope };
}
async getPublicState(token: string) {
const { signer, envelope } = await this.resolveSigner(token);
const actor = this.signerActor(signer, null, null);
let currentSigner = signer;
if (signer.status === "PENDING" || signer.status === "LINK_SENT") {
const updated = await signaturesRepo.updateSignerStatus(signer.id, "VIEWED", ["PENDING", "LINK_SENT"]);
if (updated) currentSigner = updated;
}
await signaturesRepo.recordEvent({ envelopeId: envelope.id, signerId: signer.id, eventType: "LINK_OPENED", ...actor });
const auditEvents = await signaturesRepo.listAuditEvents(envelope.id);
const documentsRead = new Set(
auditEvents
.filter((e) => e.signer_id === signer.id && e.event_type === "DOCUMENT_READ_CONFIRMED")
.map((e) => e.document_id),
);
return {
envelope: { id: envelope.id, envelopeNumber: envelope.envelope_number, status: envelope.status },
signer: { ...currentSigner, documentsRead: Array.from(documentsRead) },
documents: envelope.documents.map((d) => ({
id: d.id,
title: d.title,
documentVersion: d.document_version,
pageCount: d.page_count,
confirmedRead: documentsRead.has(d.id),
})),
};
}
async confirmDocumentRead(token: string, documentId: string, ctx: PublicRequestContext): Promise<void> {
const { signer, envelope } = await this.resolveSigner(token);
if (!envelope.documents.some((d) => d.id === documentId)) {
throw new BadRequestException("Documento não pertence a este envelope.");
}
await signaturesRepo.recordEvent({
envelopeId: envelope.id,
signerId: signer.id,
documentId,
eventType: "DOCUMENT_VIEWED",
...this.signerActor(signer, ctx.ipAddress, ctx.userAgent),
});
await signaturesRepo.recordEvent({
envelopeId: envelope.id,
signerId: signer.id,
documentId,
eventType: "DOCUMENT_READ_CONFIRMED",
...this.signerActor(signer, ctx.ipAddress, ctx.userAgent),
});
}
async confirmData(token: string, ctx: PublicRequestContext): Promise<void> {
const { signer, envelope } = await this.resolveSigner(token);
const updated = await signaturesRepo.updateSignerStatus(signer.id, "DATA_CONFIRMED", ["VIEWED"]);
if (!updated) {
throw new ConflictException("Não é possível confirmar os dados neste momento.");
}
await signaturesRepo.recordEvent({
envelopeId: envelope.id,
signerId: signer.id,
eventType: "CUSTOMER_DATA_CONFIRMED",
...this.signerActor(signer, ctx.ipAddress, ctx.userAgent),
});
}
/** Só permite consentir depois de TODOS os documentos confirmados como lidos — reconsultando o próprio trilho de auditoria, não uma flag separada (eden.md §10.2). */
async acceptConsent(token: string, ctx: PublicRequestContext): Promise<void> {
const { signer, envelope } = await this.resolveSigner(token);
const auditEvents = await signaturesRepo.listAuditEvents(envelope.id);
const confirmedDocIds = new Set(
auditEvents
.filter((e) => e.signer_id === signer.id && e.event_type === "DOCUMENT_READ_CONFIRMED")
.map((e) => e.document_id),
);
const allConfirmed = envelope.documents.every((d) => confirmedDocIds.has(d.id));
if (!allConfirmed) {
throw new ConflictException("Confirme a leitura de todos os documentos antes de continuar.");
}
const updated = await signaturesRepo.updateSignerStatus(signer.id, "CONSENTED", ["DATA_CONFIRMED"]);
if (!updated) {
throw new ConflictException("Não é possível registrar o consentimento neste momento.");
}
await signaturesRepo.recordEvent({
envelopeId: envelope.id,
signerId: signer.id,
eventType: "ELECTRONIC_SIGNATURE_TERMS_ACCEPTED",
...this.signerActor(signer, ctx.ipAddress, ctx.userAgent),
});
}
async requestOtp(token: string, ctx: PublicRequestContext): Promise<void> {
const { signer, envelope } = await this.resolveSigner(token);
if (signer.status !== "CONSENTED") {
throw new ConflictException("Aceite os termos de assinatura eletrônica antes de solicitar o código.");
}
const code = generateOtpCode();
const otpProtectedValue = hashOtpCode(code, this.getOtpSecret());
const challenge = await signaturesRepo.createOtpChallenge({
envelopeId: envelope.id,
signerId: signer.id,
emailSnapshot: signer.email,
otpProtectedValue,
expiresAt: new Date(Date.now() + OTP_TTL_MS),
requestIp: ctx.ipAddress,
requestUserAgent: ctx.userAgent,
});
await signaturesRepo.recordEvent({
envelopeId: envelope.id,
signerId: signer.id,
eventType: "OTP_REQUESTED",
...this.signerActor(signer, ctx.ipAddress, ctx.userAgent),
});
await sendMail({
to: signer.email,
subject: "Handix — Código de verificação para assinatura eletrônica",
html: `<p>Seu código de verificação:</p><p style="font-size:32px;letter-spacing:6px;font-weight:700;">${code}</p><p>Expira em 5 minutos e só pode ser usado uma vez.</p>`,
});
await signaturesRepo.markOtpSent(challenge.id);
await signaturesRepo.recordEvent({
envelopeId: envelope.id,
signerId: signer.id,
eventType: "OTP_SENT",
...this.signerActor(signer, ctx.ipAddress, ctx.userAgent),
});
}
async verifyOtp(token: string, code: string, ctx: PublicRequestContext): Promise<void> {
const { signer, envelope } = await this.resolveSigner(token);
const challenge = await signaturesRepo.getLatestOtpChallengeForSigner(signer.id);
const actor = this.signerActor(signer, ctx.ipAddress, ctx.userAgent);
if (!challenge) throw new BadRequestException("Nenhum código foi solicitado ainda.");
if (challenge.status === "VERIFIED") throw new ConflictException("Código já utilizado.");
if (challenge.status === "BLOCKED") {
throw new ConflictException("Muitas tentativas incorretas — solicite um novo código.");
}
if (challenge.expires_at.getTime() < Date.now()) {
await signaturesRepo.markOtpExpired(challenge.id);
await signaturesRepo.recordEvent({ envelopeId: envelope.id, signerId: signer.id, eventType: "OTP_EXPIRED", ...actor });
throw new ConflictException("Código expirado — solicite um novo.");
}
const expected = challenge.otp_protected_value;
const actual = hashOtpCode(code, this.getOtpSecret());
if (actual !== expected) {
const { attempts, blocked } = await signaturesRepo.recordOtpFailedAttempt(challenge.id, challenge.max_attempts ?? OTP_DEFAULT_MAX_ATTEMPTS);
await signaturesRepo.recordEvent({
envelopeId: envelope.id,
signerId: signer.id,
eventType: "OTP_FAILED",
...actor,
metadata: { attempts },
});
if (blocked) {
await signaturesRepo.recordEvent({ envelopeId: envelope.id, signerId: signer.id, eventType: "OTP_BLOCKED", ...actor });
}
throw new BadRequestException("Código incorreto.");
}
await signaturesRepo.markOtpVerified(challenge.id, ctx.ipAddress, ctx.userAgent);
await signaturesRepo.updateSignerStatus(signer.id, "OTP_VERIFIED", [
"PENDING",
"LINK_SENT",
"VIEWED",
"DATA_CONFIRMED",
"CONSENTED",
"OTP_VERIFIED",
]);
await signaturesRepo.recordEvent({ envelopeId: envelope.id, signerId: signer.id, eventType: "OTP_VERIFIED", ...actor });
}
/** Requer signer.status === 'OTP_VERIFIED' exatamente. Se este for o último signatário pendente, transiciona o envelope SENT->SIGNED diretamente (eden.md §5.1 nota de design). A finalização em si (selar checkpoint + certificado + PDF consolidado) continua sendo uma ação explícita do backoffice (EnvelopeFinalizeService, via API autenticada) — deliberadamente NÃO automática, mesmo o EDEN podendo gerar o certificado no servidor sem depender do navegador de ninguém: o checkpoint humano antes da finalização irreversível é comportamento de negócio do legado, não um detalhe técnico do desenho client-side do certificado. */
async commitSignature(token: string, ctx: PublicRequestContext): Promise<void> {
const { signer, envelope } = await this.resolveSigner(token);
const actor = this.signerActor(signer, ctx.ipAddress, ctx.userAgent);
await signaturesRepo.recordEvent({ envelopeId: envelope.id, signerId: signer.id, eventType: "SIGN_BUTTON_PRESSED", ...actor });
const updated = await signaturesRepo.updateSignerStatus(signer.id, "SIGNED", ["OTP_VERIFIED"]);
if (!updated) {
throw new ConflictException("Valide o código de verificação antes de assinar.");
}
await signaturesRepo.recordEvent({ envelopeId: envelope.id, signerId: signer.id, eventType: "SIGNATURE_COMMITTED", ...actor });
const refreshed = await signaturesRepo.getEnvelopeById(envelope.id);
const allDone = refreshed!.signers.every((s) => s.status === "SIGNED" || s.status === "DECLINED");
if (allDone) {
await signaturesRepo.updateEnvelopeStatus(envelope.id, "SIGNED");
await signaturesRepo.recordEvent({
envelopeId: envelope.id,
eventType: "ENVELOPE_SIGNED",
actorType: "SYSTEM",
actorId: null,
actorRole: null,
});
}
}
private signerActor(signer: SignatureSigner, ipAddress: string | null, userAgent: string | null) {
const parsed = parseUserAgent(userAgent);
return {
actorType: "SIGNER" as const,
actorId: null,
actorRole: signer.party,
ipAddress,
userAgent,
browser: parsed.browser,
operatingSystem: parsed.operatingSystem,
deviceType: parsed.deviceType,
sessionId: (signer.secure_token_hash ?? "").slice(0, 12),
};
}
}

View File

@@ -0,0 +1,14 @@
import { Controller, Get, Param } from "@nestjs/common";
import { ApiTags } from "@nestjs/swagger";
import { PublicVerifyService } from "./public-verify.service.js";
@ApiTags("public-verify")
@Controller("v1/public-verify")
export class PublicVerifyController {
constructor(private readonly publicVerify: PublicVerifyService) {}
@Get(":verificationId")
verify(@Param("verificationId") verificationId: string) {
return this.publicVerify.verify(verificationId);
}
}

View File

@@ -0,0 +1,51 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { signaturesRepo } from "@eden/database";
import { computeEnvelopeHash } from "./envelope-hash.js";
/** Só a primeira letra de cada palavra do nome (eden.md §9) — nunca CPF, nunca e-mail. */
function maskName(fullName: string): string {
return fullName
.trim()
.split(/\s+/)
.map((word) => `${word[0] ?? ""}${"*".repeat(Math.max(word.length - 1, 0))}`)
.join(" ");
}
/**
* Rota pública de verificação — eden.md §9. Sem autenticação, só
* retorna algo para envelopes COMPLETED (qualquer outro estado é 404,
* mesmo que exista). Recomputa envelope_hash a partir dos dados ATUAIS
* de documentos/signatários e compara com o valor gravado na
* finalização — prova de integridade independente do PDF em si.
*/
@Injectable()
export class PublicVerifyService {
async verify(verificationId: string) {
const envelope = await signaturesRepo.getEnvelopeByVerificationId(verificationId);
if (!envelope || envelope.status !== "COMPLETED") {
throw new NotFoundException("Registro não encontrado.");
}
const chain = await signaturesRepo.verifyChain(envelope.id);
const recomputedHash = computeEnvelopeHash(envelope, envelope.evidence_checkpoint_hash ?? "");
const envelopeHashValid = recomputedHash === envelope.envelope_hash;
return {
envelopeNumber: envelope.envelope_number,
completedAt: envelope.completed_at,
documents: envelope.documents.map((d) => ({
title: d.title,
version: d.document_version,
pageCount: d.page_count,
})),
signers: envelope.signers.map((s) => ({ name: maskName(s.full_name), party: s.party })),
integrityValid: chain.valid && envelopeHashValid,
eventsChecked: chain.eventsChecked,
envelopeHash: envelope.envelope_hash,
finalPdfSha256: envelope.final_pdf_sha256,
evidenceCheckpointHash: envelope.evidence_checkpoint_hash,
verificationId: envelope.verification_id,
method: "Assinatura eletrônica — código de uso único (OTP) por e-mail",
};
}
}

View File

@@ -0,0 +1,91 @@
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
import { EnvelopeService } from "./envelope.service.js";
import { EnvelopeFinalizeService } from "./finalize.service.js";
import { CreateEnvelopeDto } from "./dto/create-envelope.dto.js";
import { SendInviteDto } from "./dto/send-invite.dto.js";
import { CancelEnvelopeDto } from "./dto/cancel-envelope.dto.js";
/**
* Assinatura Eletrônica — rotas autenticadas de backoffice (eden.md §5,
* §11 feature key `assinaturas`). O fluxo público do signatário
* (token do link, sem autenticação) fica em public-signature.controller.ts.
*/
@ApiTags("signature-envelopes")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("v1/signature-envelopes")
export class SignatureEnvelopesController {
constructor(
private readonly envelopes: EnvelopeService,
private readonly finalize: EnvelopeFinalizeService,
) {}
@Get()
@RequirePermission("signatures", "view")
list(@CurrentUser() user: EdenAuthenticatedUser) {
return this.envelopes.list(user);
}
@Get(":id")
@RequirePermission("signatures", "view")
get(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.envelopes.get(id, user);
}
@Post()
@RequirePermission("signatures", "edit")
create(@Body() dto: CreateEnvelopeDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.envelopes.create(dto, user);
}
@Post(":id/send")
@RequirePermission("signatures", "edit")
sendInvite(@Param("id") id: string, @Body() dto: SendInviteDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.envelopes.sendInvite(id, dto, user);
}
@Post(":id/cancel")
@RequirePermission("signatures", "edit")
cancel(@Param("id") id: string, @Body() dto: CancelEnvelopeDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.envelopes.cancel(id, dto, user);
}
/** Exclusão física — só antes de SIGNED (eden.md §1.9). Depois disso, só /cancel. */
@Delete(":id")
@RequirePermission("signatures", "delete")
delete(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.envelopes.delete(id, user);
}
@Get(":id/verify-audit")
@RequirePermission("signatures", "view")
verifyAudit(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.envelopes.verifyAudit(id, user);
}
@Get(":id/audit-events")
@RequirePermission("signatures", "view")
listAuditEvents(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.envelopes.listAuditEvents(id, user);
}
/** Passo 1 da finalização — pré-condição status SIGNED (eden.md §8.2). */
@Post(":id/seal-checkpoint")
@RequirePermission("signatures", "edit")
sealCheckpoint(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.finalize.sealCheckpoint(id, user);
}
/** Passo 2 — pré-condição status FINALIZING (eden.md §8.3). Renderiza o certificado e consolida o PDF final no próprio servidor. */
@Post(":id/finalize")
@RequirePermission("signatures", "edit")
finalizeEnvelope(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.finalize.finalize(id, user);
}
}

View File

@@ -0,0 +1,17 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { SignatureEnvelopesController } from "./signatures.controller.js";
import { PublicSignatureController } from "./public-signature.controller.js";
import { PublicVerifyController } from "./public-verify.controller.js";
import { EnvelopeService } from "./envelope.service.js";
import { EnvelopeFinalizeService } from "./finalize.service.js";
import { PublicSignatureService } from "./public-signature.service.js";
import { PublicVerifyService } from "./public-verify.service.js";
@Module({
imports: [AuthModule],
controllers: [SignatureEnvelopesController, PublicSignatureController, PublicVerifyController],
providers: [EnvelopeService, EnvelopeFinalizeService, PublicSignatureService, PublicVerifyService],
exports: [EnvelopeService, EnvelopeFinalizeService],
})
export class SignaturesModule {}

View File

@@ -0,0 +1,18 @@
import { createHash, randomBytes } from "node:crypto";
/**
* Secure signer link token — eden.md §10.1-10.2. Plain SHA-256 (not
* HMAC, unlike OTP): the token already has 256 bits of entropy, so
* there's no small-search-space to defend against with a server secret —
* simple hash suffices. Never search by the plaintext token; only ever
* by its hash.
*/
export const SIGNER_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export function generateSignerToken(): string {
return randomBytes(32).toString("hex");
}
export function hashSignerToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}

View File

@@ -0,0 +1,38 @@
/**
* Lightweight User-Agent parsing for the signature audit trail —
* deliberately NOT a full fingerprinting library (eden.md §6.9: "só IP +
* user-agent, parse leve... por decisão de produto de não fazer
* fingerprinting invasivo"). Best-effort labels only, never used for any
* security decision — just printed on the certificate's signer timeline.
*/
export interface ParsedUserAgent {
browser: string | null;
operatingSystem: string | null;
deviceType: string | null;
}
export function parseUserAgent(userAgent: string | null | undefined): ParsedUserAgent {
if (!userAgent) return { browser: null, operatingSystem: null, deviceType: null };
let browser: string | null = null;
if (/edg\//i.test(userAgent)) browser = "Edge";
else if (/opr\//i.test(userAgent)) browser = "Opera";
else if (/chrome\//i.test(userAgent)) browser = "Chrome";
else if (/firefox\//i.test(userAgent)) browser = "Firefox";
else if (/safari\//i.test(userAgent)) browser = "Safari";
let operatingSystem: string | null = null;
if (/windows/i.test(userAgent)) operatingSystem = "Windows";
else if (/mac os x/i.test(userAgent)) operatingSystem = "macOS";
else if (/android/i.test(userAgent)) operatingSystem = "Android";
else if (/iphone|ipad|ios/i.test(userAgent)) operatingSystem = "iOS";
else if (/linux/i.test(userAgent)) operatingSystem = "Linux";
const deviceType = /mobile|android|iphone/i.test(userAgent)
? "mobile"
: /ipad|tablet/i.test(userAgent)
? "tablet"
: "desktop";
return { browser, operatingSystem, deviceType };
}