/** * 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; `); };