Files
eden/packages/database/migrations/1788536022387_signatures.cjs
Matheus (Handix) 20b114a0bf feat(signatures): módulo completo de assinatura eletrônica
Porta o motor de assinatura do legado com fidelidade algorítmica
exata — hash-chain (SHA256 encadeado, seq bigserial define a ordem,
não timestamp), OTP (6 dígitos via crypto.randomInt, HMAC-SHA256 com
SIGNATURE_OTP_SECRET, TTL 5min, 5 tentativas, único ativo por vez) e
a máquina de estados de 19 estados do envelope + 8 do signatário —
tudo re-verificado contra o código-fonte real do legado por um
subagente dedicado antes de qualquer linha ser escrita, cruzado com
eden.md módulo 4.

5 tabelas novas (signature_envelopes/signers/documents/
otp_challenges/audit_events, esta última append-only via a mesma GUC
eden.allow_audit_mutation que já protege audit_log desde a Fase 1 —
canonicalização de hash extraída para packages/database/src/
hash-chain.ts, compartilhada entre as duas). Três adaptações
deliberadas ao EDEN (documentadas na migration): documentos vêm de
document_generations em vez de um módulo de anexo genérico que não
existe ainda; reseller_id em vez de reseller_registration_id; só a
versão correta (v2) da canonicalização, sem a v1 do legado (existia
lá só para tolerar hashes pré-correção de bug, EDEN nunca teve isso).

Certificado de conclusão renderizado no servidor via Chromium (não
client-side como no legado) — elimina o upload multipart do
certificado pronto. Os dois passos autenticados de finalização
(seal-checkpoint, finalize) continuam explícitos e manuais, não
automáticos, mesmo sendo tecnicamente possível agora — esse
checkpoint humano antes de uma finalização irreversível é
comportamento de negócio herdado do legado, não um detalhe do
desenho client-side do certificado que deixou de existir.

Fluxo público (token do link, nunca envelopeId/signerId do corpo da
requisição) e verificação pública (só para COMPLETED, recomputa
envelope_hash ao vivo, nomes mascarados) inteiros.

Verificado: 28 testes novos (15 puros — hash-chain/máquina de
estados/OTP/token — e 13 de ciclo de vida completo via HTTP real,
incluindo merge de PDF de verdade via pdf-lib contra saída real do
Chromium mockado). Suíte completa: 70/70 verdes. Testado ao vivo
através do container real, ponta a ponta, sem nenhum mock: criação →
convite → fluxo público completo dos dois signatários (OTP
recuperado via HMAC com o segredo real, já que SMTP não está
configurado neste ambiente) → SIGNED → seal-checkpoint → finalize
(certificado real via Chromium, merge real, upload real no MinIO) →
public-verify — SHA-256 do PDF final baixado bate byte a byte com o
gravado no Postgres, integrityValid:true.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx
2026-09-04 13:44:43 -03:00

284 lines
19 KiB
JavaScript

/**
* Assinatura Eletrônica — eden.md módulo 4, §§1.4-1.9 e 5-10. Deliberadamente
* adiado desde 1788468521075_documents.cjs ("cryptographically/legally
* sensitive... needs its own dedicated migration and review"). Ported com
* fidelidade algorítmica exata (máquina de estados, hash-chain, OTP) — as
* únicas divergências deliberadas do legado, documentadas caso a caso
* abaixo, são adaptações à própria arquitetura do EDEN, nunca ao conteúdo
* de segurança/negócio em si:
*
* - `signature_documents.source_generation_id` referencia
* `document_generations(id)` em vez do `source_attachment_id` do
* legado — EDEN ainda não tem um módulo de upload de anexo genérico
* (client_registration_attachments/reseller_registration_attachments
* nunca foram portados), mas já tem `document_generations` (PDFs reais,
* gerados via Chromium, com SHA-256 e S3 desde a leva anterior desta
* sessão) — a fonte natural de "documento já gerado, hasheado, pronto
* para congelar" no EDEN. Igual ao legado, é só referência histórica:
* uma vez congelado em `signature_documents`, a cópia S3 própria e o
* hash já congelados são a fonte da verdade, não a FK.
* - `reseller_id` (FK para `resellers`) em vez de `reseller_registration_id`
* — o EDEN não tem uma entidade "cadastro de revenda" separada da
* revenda em si (eden.md documenta `reseller_registration_id` como a
* origem alternativa a `client_registration_id`, mas o EDEN modela
* revenda como uma única tabela `resellers`, não duas). Os dados do
* signatário `contratante` são sempre informados explicitamente na
* criação do envelope em ambos os sistemas (nunca derivados
* automaticamente do cadastro de origem) — só o signatário
* `contratada` é auto-preenchido, de `companies.legal_rep_*` — então
* essa troca não perde nenhum dado necessário.
* - `hash_algorithm_version` existe mas só a versão 2 (a fórmula correta,
* canonicalização recursiva em profundidade) é usada — ver o
* doc-comment de packages/database/src/hash-chain.ts para o porquê da
* v1 do legado (compat com hashes já gravados antes de uma correção de
* bug real) não fazer sentido aqui: o EDEN nunca teve uma v1 buggy.
* - Certificado de conclusão: o legado desenha client-side (React →
* html2canvas → jsPDF, recebido via upload multipart em
* `finalizeEnvelope`). O EDEN já tem Chromium server-side real
* (packages/integrations/src/pdf/renderer.ts, construído nesta mesma
* sessão) — o certificado é renderizado no servidor a partir dos
* mesmos dados já gravados (evidence_checkpoint_hash/verification_id/
* envelope_hash), eliminando o upload multipart do passo 2 inteiramente
* (o servidor nunca precisa confiar num PDF vindo de fora como "o
* certificado"). Ver apps/api/src/signatures/.
*
* Tudo o mais — os 19 estados do envelope, os 8 estados do signatário, o
* algoritmo de hash-chain (SHA256(previous + canonical_json(campos)),
* `seq` bigserial definindo a ordem, não timestamp), o OTP (6 dígitos via
* crypto.randomInt, HMAC-SHA256 com SIGNATURE_OTP_SECRET, TTL de 5min,
* máx. 5 tentativas, um único código ativo por vez), o token de link
* público (32 bytes aleatórios, só o SHA-256 persistido, 7 dias de
* validade), o processo de finalização em dois passos (selar checkpoint
* ANTES do certificado existir, depois consolidar via pdf-lib) — tudo isso
* é fidelidade exata ao legado, verificado contra o código-fonte real
* (não reconstruído de memória).
*/
exports.shorthands = undefined;
exports.up = (pgm) => {
pgm.sql(`
-- =====================================================================
-- signature_envelopes — o agregado raiz
-- =====================================================================
CREATE SEQUENCE signature_envelope_seq START 1;
CREATE TABLE signature_envelopes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id),
envelope_number TEXT UNIQUE NOT NULL,
company_id UUID NOT NULL REFERENCES companies(id),
client_registration_id UUID REFERENCES client_registrations(id),
reseller_id UUID REFERENCES resellers(id),
status TEXT NOT NULL DEFAULT 'DRAFT' CHECK (status IN (
'DRAFT', 'READY', 'SENT', 'VIEWED', 'IDENTITY_PENDING', 'CONSENT_PENDING',
'OTP_PENDING', 'OTP_SENT', 'OTP_VERIFIED', 'READY_TO_SIGN', 'SIGNING', 'SIGNED',
'FINALIZING', 'COMPLETED', 'DECLINED', 'EXPIRED', 'CANCELLED', 'SUPERSEDED', 'ERROR'
)),
verification_id TEXT UNIQUE,
final_s3_key TEXT,
final_pdf_sha256 TEXT,
certificate_s3_key TEXT,
evidence_checkpoint_hash TEXT,
envelope_hash TEXT,
sent_at TIMESTAMPTZ,
viewed_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
declined_at TIMESTAMPTZ,
cancelled_at TIMESTAMPTZ,
expired_at TIMESTAMPTZ,
signed_at TIMESTAMPTZ,
CONSTRAINT chk_signature_envelopes_one_source CHECK (
(client_registration_id IS NOT NULL)::int + (reseller_id IS NOT NULL)::int = 1
)
);
COMMENT ON TABLE signature_envelopes IS
'Pacote de assinatura — conjunto de documentos + signatários passando por uma máquina de estados única (eden.md §1.4, §5.1). envelope_number formato SIG-{ano}-{seq6digitos}, gerado pela camada de repositório (mesmo padrão de contracts.contract_number).';
COMMENT ON COLUMN signature_envelopes.company_id IS
'Empresa emissora (contratada) — resolve o signatário party=contratada automático a partir de companies.legal_rep_{name,cpf,email,role}. Obrigatório (NOT NULL, diferente de contracts.company_id que é opcional): createEnvelope recusa criar o envelope se essa empresa não tiver representante legal cadastrado, eden.md §1.5/§5.4.';
COMMENT ON COLUMN signature_envelopes.status IS
'Máquina de estados centralizada no backend, nunca confiada ao frontend — ver ALLOWED_TRANSITIONS em apps/api/src/signatures/state-machine.ts. SENT->SIGNED é um salto direto deliberado: o progresso fino (VIEWED..OTP_VERIFIED) é rastreado por signatário (signature_signers.status), não replicado passo a passo no envelope.';
COMMENT ON COLUMN signature_envelopes.verification_id IS
'16 bytes aleatórios em hex (crypto.randomBytes(16)), gerado só na finalização (sealEvidenceCheckpoint) — usado na URL pública de verificação /verify/{verificationId}.';
COMMENT ON COLUMN signature_envelopes.evidence_checkpoint_hash IS
'event_hash do último evento de auditoria do envelope no momento do selamento (sealEvidenceCheckpoint) — NÃO é o hash final absoluto da auditoria inteira, a cadeia continua recebendo eventos depois (ex. CERTIFICATE_GENERATED).';
COMMENT ON COLUMN signature_envelopes.envelope_hash IS
'SHA-256 dos fatos lógicos do envelope (envelope_number, evidence_checkpoint_hash, documentos e signatários ordenados por sort_order) — terceira camada de integridade, independente do PDF em bytes. Ver computeEnvelopeHash em apps/api/src/signatures/finalize.service.ts.';
CREATE TRIGGER trg_signature_envelopes_updated_at BEFORE UPDATE ON signature_envelopes
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE INDEX idx_signature_envelopes_status ON signature_envelopes(status);
CREATE INDEX idx_signature_envelopes_client_registration ON signature_envelopes(client_registration_id);
CREATE INDEX idx_signature_envelopes_reseller ON signature_envelopes(reseller_id);
ALTER SEQUENCE signature_envelope_seq OWNED BY NONE;
-- =====================================================================
-- signature_signers — snapshot, nunca referência viva (eden.md §1.5)
-- =====================================================================
CREATE TABLE signature_signers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
envelope_id UUID NOT NULL REFERENCES signature_envelopes(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
party TEXT NOT NULL DEFAULT 'contratante' CHECK (party IN ('contratada', 'contratante')),
person_type TEXT CHECK (person_type IS NULL OR person_type IN ('pf', 'pj')),
full_name TEXT NOT NULL,
cpf TEXT,
email TEXT NOT NULL,
phone TEXT,
company_name TEXT,
cnpj TEXT,
role_title TEXT,
status TEXT NOT NULL DEFAULT 'PENDING' CHECK (status IN (
'PENDING', 'LINK_SENT', 'VIEWED', 'DATA_CONFIRMED', 'CONSENTED',
'OTP_VERIFIED', 'SIGNED', 'DECLINED'
)),
signed_at TIMESTAMPTZ,
secure_token_hash TEXT UNIQUE,
secure_token_created_at TIMESTAMPTZ,
secure_token_expires_at TIMESTAMPTZ,
secure_token_revoked_at TIMESTAMPTZ,
secure_token_first_used_at TIMESTAMPTZ,
secure_token_last_used_at TIMESTAMPTZ
);
COMMENT ON TABLE signature_signers IS
'Um signatário dentro de um envelope — SEMPRE um snapshot (nome/CPF/e-mail congelados no momento da criação do envelope), nunca FK viva para o cadastro do cliente. Todo envelope tem exatamente um party=''contratada'' (auto-preenchido de companies.legal_rep_*) e um ou mais party=''contratante'' (informados explicitamente na criação, eden.md §5.3).';
COMMENT ON COLUMN signature_signers.secure_token_hash IS
'SHA-256 simples (não HMAC) do token do link público — o "segredo" aqui é a entropia do token (32 bytes/256 bits via crypto.randomBytes), diferente do OTP (HMAC porque o espaço de 6 dígitos é pequeno). Nunca busca pelo token em claro.';
COMMENT ON COLUMN signature_signers.status IS
'PENDING -> LINK_SENT -> VIEWED -> DATA_CONFIRMED -> CONSENTED -> OTP_VERIFIED -> SIGNED (ou DECLINED a qualquer momento — reservado, sem rota que o produza ainda, igual ao legado). Toda escrita usa guarda AND status NOT IN (...) para nunca regredir um signatário já avançado.';
CREATE INDEX idx_signature_signers_envelope ON signature_signers(envelope_id);
-- =====================================================================
-- signature_documents — documento congelado (cópia imutável)
-- =====================================================================
CREATE TABLE signature_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
envelope_id UUID NOT NULL REFERENCES signature_envelopes(id) ON DELETE CASCADE,
source_generation_id UUID REFERENCES document_generations(id),
document_type TEXT,
title TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
document_version INTEGER NOT NULL DEFAULT 1,
source_filename TEXT,
source_mime_type TEXT,
source_s3_key TEXT NOT NULL,
original_sha256 TEXT NOT NULL,
page_count INTEGER,
status TEXT NOT NULL DEFAULT 'FROZEN' CHECK (status IN ('FROZEN', 'SUPERSEDED')),
superseded_by_id UUID REFERENCES signature_documents(id),
frozen_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE signature_documents IS
'Cópia congelada e imutável de um documento dentro do envelope — source_s3_key é SEMPRE uma cópia própria (nunca o objeto S3 mutável de document_generations), original_sha256 calculado no momento do congelamento. source_generation_id é só referência histórica, nunca usado operacionalmente depois de congelado (eden.md §1.6, mesma filosofia do source_attachment_id do legado).';
CREATE INDEX idx_signature_documents_envelope ON signature_documents(envelope_id);
-- =====================================================================
-- signature_otp_challenges — ver eden.md §6 para o algoritmo completo
-- =====================================================================
CREATE TABLE signature_otp_challenges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
envelope_id UUID NOT NULL REFERENCES signature_envelopes(id) ON DELETE CASCADE,
signer_id UUID NOT NULL REFERENCES signature_signers(id) ON DELETE CASCADE,
channel TEXT NOT NULL DEFAULT 'EMAIL',
email_snapshot TEXT,
otp_protected_value TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
sent_at TIMESTAMPTZ,
verified_at TIMESTAMPTZ,
invalidated_at TIMESTAMPTZ,
attempts_count INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 5,
request_ip INET,
verification_ip INET,
request_user_agent TEXT,
verification_user_agent TEXT,
status TEXT NOT NULL DEFAULT 'CREATED' CHECK (status IN (
'CREATED', 'SENT', 'VERIFIED', 'EXPIRED', 'INVALIDATED', 'BLOCKED'
))
);
COMMENT ON TABLE signature_otp_challenges IS
'otp_protected_value é SEMPRE HMAC-SHA256(código, SIGNATURE_OTP_SECRET) — o código de 6 dígitos puro nunca é gravado, logado, ou devolvido em resposta de API. TTL de 5 minutos fixo (não configurável). Um novo OTP invalida (status=INVALIDATED) qualquer challenge CREATED/SENT anterior do mesmo signatário antes de criar outro — eden.md §6.5.';
COMMENT ON COLUMN signature_otp_challenges.max_attempts IS
'Default 5 — ao atingir attempts_count >= max_attempts, status vira BLOCKED e nenhuma tentativa adicional é aceita nesse challenge; o signatário precisa pedir um novo OTP.';
CREATE INDEX idx_signature_otp_challenges_signer ON signature_otp_challenges(signer_id, created_at DESC);
-- =====================================================================
-- signature_audit_events — cadeia de auditoria hash-chained, append-only
-- =====================================================================
CREATE TABLE signature_audit_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
seq BIGSERIAL NOT NULL UNIQUE,
envelope_id UUID NOT NULL REFERENCES signature_envelopes(id) ON DELETE CASCADE,
document_id UUID REFERENCES signature_documents(id),
signer_id UUID REFERENCES signature_signers(id),
event_type TEXT NOT NULL,
actor_type TEXT NOT NULL CHECK (actor_type IN (
'SIGNER', 'BACKOFFICE', 'ADMIN', 'SUPER_ADMIN', 'SYSTEM', 'SERVICE'
)),
actor_id UUID REFERENCES users(id),
actor_role TEXT,
timestamp_utc TIMESTAMPTZ NOT NULL,
ip_address INET,
user_agent TEXT,
browser TEXT,
operating_system TEXT,
device_type TEXT,
session_id TEXT,
correlation_id TEXT,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
previous_event_hash TEXT,
event_hash TEXT NOT NULL,
hash_algorithm_version INTEGER NOT NULL DEFAULT 2
);
COMMENT ON TABLE signature_audit_events IS
'Cadeia de auditoria hash-chained POR ENVELOPE (distinta e complementar ao audit_log genérico do sistema, que cobre toda ação crítica — as duas coexistem deliberadamente, eden.md §1.8). seq (bigserial), não timestamp_utc, define a ordem real: dentro de uma mesma transação o Postgres congela now(), então vários eventos gravados juntos podem ter timestamp idêntico. event_hash = SHA256(previous_event_hash + canonical_json({envelopeId,documentId,signerId,eventType,actorType,actorId,actorRole,timestampUtc,ipAddress,userAgent,metadata})) — note que browser/operatingSystem/deviceType/sessionId/correlationId ficam gravados na linha mas NÃO entram no hash. previous_event_hash encadeia com o evento anterior DO MESMO envelope, string vazia se for o primeiro.';
COMMENT ON COLUMN signature_audit_events.hash_algorithm_version IS
'Sempre 2 neste sistema (não existe v1 aqui — ver doc-comment de packages/database/src/hash-chain.ts). Mantido para o mesmo padrão defensivo do legado: se um dia a fórmula precisar mudar de novo, a verificação continua sabendo qual fórmula cada evento antigo usou.';
CREATE INDEX idx_signature_audit_events_envelope ON signature_audit_events(envelope_id, seq);
-- Append-only, reforçado em nível de banco — mesmo padrão e mesma GUC
-- (eden.allow_audit_mutation) já usada por audit_log
-- (1788430575858_baseline-schema.cjs), reaproveitada aqui de propósito:
-- é a mesma classe de escotilha administrativa deliberada, não uma
-- flag nova por tabela. Bloqueia inclusive super_admin via SQL bruto.
CREATE OR REPLACE FUNCTION prevent_signature_audit_mutation() RETURNS TRIGGER AS $$
BEGIN
IF current_setting('eden.allow_audit_mutation', true) IS DISTINCT FROM 'true' THEN
RAISE EXCEPTION 'signature_audit_events é append-only — mutação bloqueada (ver docs/security/threat-model.md)';
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_signature_audit_events_no_update BEFORE UPDATE ON signature_audit_events
FOR EACH ROW EXECUTE FUNCTION prevent_signature_audit_mutation();
CREATE TRIGGER trg_signature_audit_events_no_delete BEFORE DELETE ON signature_audit_events
FOR EACH ROW EXECUTE FUNCTION prevent_signature_audit_mutation();
`);
};
exports.down = (pgm) => {
pgm.sql(`
DROP TABLE IF EXISTS signature_audit_events;
DROP FUNCTION IF EXISTS prevent_signature_audit_mutation();
DROP TABLE IF EXISTS signature_otp_challenges;
DROP TABLE IF EXISTS signature_documents;
DROP TABLE IF EXISTS signature_signers;
DROP TABLE IF EXISTS signature_envelopes;
DROP SEQUENCE IF EXISTS signature_envelope_seq;
`);
};