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:
@@ -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],
|
||||
})
|
||||
|
||||
221
apps/api/src/signatures/certificate-template.ts
Normal file
221
apps/api/src/signatures/certificate-template.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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; }
|
||||
`;
|
||||
}
|
||||
5
apps/api/src/signatures/dto/cancel-envelope.dto.ts
Normal file
5
apps/api/src/signatures/dto/cancel-envelope.dto.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class CancelEnvelopeDto {
|
||||
@IsOptional() @IsString() reason?: string;
|
||||
}
|
||||
51
apps/api/src/signatures/dto/create-envelope.dto.ts
Normal file
51
apps/api/src/signatures/dto/create-envelope.dto.ts
Normal 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[];
|
||||
}
|
||||
6
apps/api/src/signatures/dto/send-invite.dto.ts
Normal file
6
apps/api/src/signatures/dto/send-invite.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsIn } from "class-validator";
|
||||
|
||||
export class SendInviteDto {
|
||||
@IsIn(["contratada", "contratante", "both"])
|
||||
targets!: "contratada" | "contratante" | "both";
|
||||
}
|
||||
5
apps/api/src/signatures/dto/verify-otp.dto.ts
Normal file
5
apps/api/src/signatures/dto/verify-otp.dto.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { IsString, Length } from "class-validator";
|
||||
|
||||
export class VerifyOtpDto {
|
||||
@IsString() @Length(6, 6) code!: string;
|
||||
}
|
||||
30
apps/api/src/signatures/envelope-hash.ts
Normal file
30
apps/api/src/signatures/envelope-hash.ts
Normal 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");
|
||||
}
|
||||
234
apps/api/src/signatures/envelope.service.ts
Normal file
234
apps/api/src/signatures/envelope.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
155
apps/api/src/signatures/finalize.service.ts
Normal file
155
apps/api/src/signatures/finalize.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
24
apps/api/src/signatures/otp.ts
Normal file
24
apps/api/src/signatures/otp.ts
Normal 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");
|
||||
}
|
||||
61
apps/api/src/signatures/public-signature.controller.ts
Normal file
61
apps/api/src/signatures/public-signature.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
268
apps/api/src/signatures/public-signature.service.ts
Normal file
268
apps/api/src/signatures/public-signature.service.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
14
apps/api/src/signatures/public-verify.controller.ts
Normal file
14
apps/api/src/signatures/public-verify.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
51
apps/api/src/signatures/public-verify.service.ts
Normal file
51
apps/api/src/signatures/public-verify.service.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
91
apps/api/src/signatures/signatures.controller.ts
Normal file
91
apps/api/src/signatures/signatures.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
17
apps/api/src/signatures/signatures.module.ts
Normal file
17
apps/api/src/signatures/signatures.module.ts
Normal 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 {}
|
||||
18
apps/api/src/signatures/token.ts
Normal file
18
apps/api/src/signatures/token.ts
Normal 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");
|
||||
}
|
||||
38
apps/api/src/signatures/user-agent.ts
Normal file
38
apps/api/src/signatures/user-agent.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user