Appearance
Criptografia e segredos
O flow-ai-core é a única peça do sistema que conhece a chave mestra CRYPTO_SECRET e, portanto, o único serviço capaz de cifrar/decifrar os campos *Encrypted do Postgres. Todos os outros serviços leem os segredos que precisam do Redis — já decifrados pelo core — sem jamais ter acesso à chave mestra nem tocar nas colunas cifradas do banco (alguns até usam Prisma para outras entidades, mas nunca decifram nada). A única exceção é o flow-ai-webchat-gateway, que roda bcrypt no sessionKey — um hash unidirecional que não usa CRYPTO_SECRET. Este guia mapeia exatamente o que é cifrado, onde, em qual formato, e o que nunca sai via HTTP.
A chave mestra
Toda criptografia simétrica da plataforma deriva de uma única variável de ambiente:
CRYPTO_SECRET=<32 bytes>O Zod valida z.string().length(32) na inicialização — o flow-ai-core rejeita boot com chave ausente ou de tamanho errado. Um único CRYPTO_SECRET protege todos os campos cifrados no Postgres.
Algoritmos usados
AES-256-CBC (simétrico)
Usado para todos os secrets persistidos no Postgres:
typescript
// encrypt(plaintext) → "iv_hex:ciphertext_hex"
const iv = randomBytes(16) // IV aleatório a cada chamada
const cipher = createCipheriv("aes-256-cbc", Buffer.from(CRYPTO_SECRET), iv)
let encrypted = cipher.update(data, "utf8", "hex")
encrypted += cipher.final("hex")
return `${iv.toString("hex")}:${encrypted}` // IV embarcado no valor cifrado
// decrypt("iv_hex:ciphertext_hex") → plaintext
const [ivHex, ciphertext] = encryptedData.split(":")
const decipher = createDecipheriv("aes-256-cbc", Buffer.from(CRYPTO_SECRET), Buffer.from(ivHex, "hex"))
let decrypted = decipher.update(ciphertext, "hex", "utf8")
decrypted += decipher.final("utf8")O IV é gerado aleatoriamente a cada cifragem — o mesmo plaintext produz ciphertexts diferentes em chamadas distintas. Isso impede ataques de análise de padrões mesmo que múltiplos registros tenham o mesmo valor.
RSA-2048 (assimétrico)
Usado exclusivamente para decifrar payloads de WhatsApp Flows (formulários interativos da Meta). A Meta cifra o payload do formulário com a chave pública do número antes de enviar no webhook:
typescript
// Geração do par de chaves (feita pelo flow-ai-core sob demanda)
generateRsaPassphrase(): string
→ randomBytes(32).toString("hex") // 64 chars hex
generateRsaKeyPair(passphrase: string): { publicKeyPem, privateKeyPem }
→ type: "rsa", modulusLength: 2048
→ publicKeyEncoding: { type: "spki", format: "pem" }
→ privateKeyEncoding: { type: "pkcs8", format: "pem", cipher: "aes-256-cbc", passphrase }A chave privada já sai do generateKeyPair do Node.js protegida pela passphrase (PKCS#8 cifrado com AES-256-CBC). Antes de persistir no banco, é cifrada uma segunda vez com Crypto.encrypt().
bcrypt (hash de identidade)
Usado para dois segredos de autenticação, ambos via bcryptjs:
typescript
// Senha de usuário (flow-ai-core) — custo 8, salt automático
hash(password, 8)
compare(inputPassword, storedHash)
// sessionKey do WebChat (flow-ai-webchat-gateway) — custo 10
hash(sessionKey, 10) // → WebChatIdentity.sessionKeyHash
compare(sessionKey, storedHash)bcrypt não é reversível — não há decrypt. Se a senha for perdida, o fluxo de recuperação gera uma nova.
Nota: o hash de
sessionKeydo WebChat roda fora doflow-ai-core, noflow-ai-webchat-gateway. É um hash unidirecional que não usaCRYPTO_SECRET— não fere a regra de que o core é o único detentor da chave mestra e o único decifrador AES. bcrypt não decifra nada, apenas compara.
Mapa completo: campos cifrados no Postgres
whatsapp_numbers
| Campo | Algoritmo | Conteúdo | Cache Redis |
|---|---|---|---|
accessTokenEncrypted | AES-256-CBC | Token de acesso Meta Cloud API | wa:credentials:{phoneNumberId} |
encryptionPrivateKeyEncrypted | AES-256-CBC (sobre PKCS#8+AES) | Chave privada RSA-2048 em PEM | wa:encryption:{phoneNumberId} |
encryptionPassphraseEncrypted | AES-256-CBC | Passphrase que protege a chave privada | wa:encryption:{phoneNumberId} |
encryptionPublicKey | plaintext | Chave pública RSA-2048 SPKI PEM | — (subido à Meta diretamente) |
A chave privada RSA passa por dupla proteção: primeiro o Node.js a gera já cifrada com a passphrase (PKCS#8), e depois o Crypto.encrypt() cifra esse PEM uma vez mais. Na hora de usar, o flow-ai-meta-api precisa: (1) decifrar com AES para obter o PEM cifrado, (2) carregar a chave com a passphrase para obter a chave privada operacional.
routers
| Campo | Algoritmo | Conteúdo | Cache Redis |
|---|---|---|---|
metaAppSecretEncrypted | AES-256-CBC | App secret para validação HMAC-SHA256 | wa:webhook:{phoneNumberId} |
metaVerifyToken | plaintext | Token de handshake do webhook (GET) | meta:verify-token:{verifyToken} |
O metaVerifyToken é plaintext em repouso porque é um discriminador de roteamento — ele é enviado pela Meta no GET de validação do webhook para o flow-ai-meta-api resolver qual Router pertence àquele token. Não é cifrado no banco como o app secret.
Ainda assim, a API administrativa não expõe o valor em claro: as rotas GET /routers e GET /routers/:id retornam apenas o booleano hasMetaVerifyToken (espelhando hasMetaAppSecret), para não vazar o token a qualquer usuário autenticado (inclusive agentes com escopo e api_clients). O valor só é gravado/atualizado via PATCH /routers/:id.
router_variables / flow_variables
| Campo | Condição | Algoritmo | Cache Redis |
|---|---|---|---|
value | isSecret = false | plaintext | vars:router:{routerId} / vars:flow:{flowId} |
valueEncrypted | isSecret = true | AES-256-CBC | vars:router:{routerId} / vars:flow:{flowId} |
RouterVariable sincroniza o cache vars:router:{routerId}; FlowVariable sincroniza vars:flow:{flowId} — chaves distintas, mesmo padrão. Ambos os campos nunca são preenchidos simultaneamente: a rota grava valueEncrypted + value: null quando isSecret=true, e o inverso quando false. Em runtime, o flow-ai-engine lê o valor já decifrado dessas chaves no Redis.
llm_credentials
| Campo | Algoritmo | Conteúdo | Cache Redis |
|---|---|---|---|
apiKeyEncrypted | AES-256-CBC | API key do provider LLM (OpenAI, Anthropic, ...) | llm:credential:{credentialId} |
Credencial global (não é scoped a Router). O flow-ai-core decifra ao popular o cache; o flow-ai-agent (e outros consumidores LLM) leem a apiKey já em claro do Redis, sem tocar Postgres nem conhecer CRYPTO_SECRET.
observability_settings
| Campo | Algoritmo | Conteúdo | Cache Redis |
|---|---|---|---|
logfireTokenEncrypted | AES-256-CBC | Write token do Logfire (OpenTelemetry → Logfire) | observability:config |
Singleton (id = "singleton"). O flow-ai-core decifra o token e grava a config resolvida (JSON com token em claro) na chave observability:config. Todos os serviços leem essa chave no boot (initTelemetry) para configurar o exporter OTLP.
instagram_accounts
| Campo | Algoritmo | Conteúdo | Cache Redis |
|---|---|---|---|
accessTokenEncrypted | AES-256-CBC | Token de acesso da Instagram Graph API | ig:credentials:{igUserId} |
igAppSecretEncrypted | AES-256-CBC | App secret do app IG (validação HMAC do webhook) | ig:webhook:{igUserId} |
Mesmo padrão do WhatsApp, mas por conta Instagram: o flow-ai-core decifra ambos ao popular os caches, e o flow-ai-ig-api lê ig:credentials:* (outbound Graph API) e ig:webhook:* (validação de assinatura) do Redis. O igAppSecret é distinto do app secret WA do Router.
integrations
| Campo | Algoritmo | Conteúdo | Cache Redis |
|---|---|---|---|
credentialsEncrypted | AES-256-CBC | Token de uma integração externa (Wiki.js, Notion, ...) | nenhum |
Caso à parte: o token da Integration nunca vai para o Redis. O flow-ai-core decifra sob demanda, dentro do próprio processo, na hora de chamar o provider externo (ex.: POST /integrations/:id/fetch-content → Wiki.js). O valor decifrado não cruza fronteira nenhuma — nem HTTP, nem cache.
users
| Campo | Algoritmo | Conteúdo |
|---|---|---|
password | bcrypt (custo 8) | Hash da senha — não reversível |
O que nunca sai via HTTP
O flow-ai-core filtra secrets na camada de serviço antes de serializar a resposta. O padrão dominante é o public type (Omit<Model, "campoEncrypted">) ou um toPublic() manual que troca o ciphertext por um booleano de presença:
| Modelo | Campo cifrado | O que o cliente recebe |
|---|---|---|
WhatsAppNumber | accessTokenEncrypted | Nada — omitido em WhatsAppNumberPublic |
InstagramAccount | accessTokenEncrypted | Nada — omitido em InstagramAccountPublic |
Router | metaAppSecretEncrypted | hasMetaAppSecret: boolean (presença, não valor) |
Router | metaVerifyToken | hasMetaVerifyToken: boolean (mesmo padrão, embora em claro no banco) |
RouterVariable / FlowVariable | valueEncrypted | value: null quando isSecret=true |
LlmCredential | apiKeyEncrypted | Nada — campo omitido do tipo de resposta |
Integration | credentialsEncrypted | hasCredentials: boolean (via toPublic manual) |
ObservabilitySettings | logfireTokenEncrypted | hasToken: boolean (presença, não valor) |
User | password | Nada — campo removido do tipo de resposta |
O padrão → hasXxx: boolean merece atenção: o frontend precisa saber se o secret está configurado (para exibir status na UI), mas não precisa do valor. Expor mesmo o ciphertext seria desnecessário — um atacante com acesso ao ciphertext e à chave conseguiria o plaintext.
Nota — dois ciphertexts escapam por um public type incompleto + ausência de
responseschema no Fastify. Os public types removem só o token principal (WhatsAppNumberPublic = Omit<WhatsAppNumber, "accessTokenEncrypted">,InstagramAccountPublic = Omit<InstagramAccount, "accessTokenEncrypted">); como as rotas de CRUD (list/get/create/patch) não declaramresponseschema, a serialização é umJSON.stringifydireto do objeto e trafega os campos não-omitidos:
WhatsAppNumber.encryptionPrivateKeyEncrypted+encryptionPassphraseEncrypted(par RSA do WhatsApp Flow)InstagramAccount.igAppSecretEncrypted(app secret do webhook IG)O impacto real é limitado: os valores saem cifrados com
CRYPTO_SECRET, que o cliente não possui — é vazamento de ciphertext, não de plaintext. Ainda assim é desnecessário: o endpoint dedicadoGET /whatsapp/numbers/:id/encryptionjá expõe apenashasPrivateKey: boolean, o padrão que essas duas listagens deveriam seguir.
Exceção intencional — existe um caminho que retorna a chave privada + passphrase decifradas via HTTP:
GET /whatsapp/numbers/:id/encryption/private-key(WhatsAppNumberEncryptionService.exportPrivateKey). É deliberado (uso em data endpoints externos de WhatsApp Flow), exige a permissãowhatsapp_numbers:write, respeita escopo de router (RBAC) e emite umlogger.warna cada export. Não é um vazamento: é uma saída explícita e auditada.
Pipeline de criptografia por fluxo
Access token do WhatsApp (criação e uso)
POST /whatsapp/numbers { accessToken: "EAAB..." }
└─ WhatsAppNumberService
├─ crypto.encrypt("EAAB...") → "a1b2...:c3d4..."
├─ Persiste: accessTokenEncrypted = "a1b2...:c3d4..."
└─ populateCaches(number)
├─ crypto.decrypt(accessTokenEncrypted) → "EAAB..."
└─ setWaCredentials(phoneNumberId, { accessToken: "EAAB..." })
→ Redis: wa:credentials:{phoneNumberId}
flow-ai-meta-api (ao enviar mensagem):
└─ getWaCredentials(phoneNumberId) ← Redis → "EAAB..."
(nunca toca Postgres, nunca conhece CRYPTO_SECRET)Chaves RSA para WhatsApp Flows (geração)
POST /whatsapp/numbers/:id/encryption/generate-keys
└─ WhatsAppNumberEncryptionService.generateKeys()
├─ passphrase = generateRsaPassphrase() → "4f9a..." (64 hex chars)
├─ { publicKeyPem, privateKeyPem } = generateRsaKeyPair(passphrase)
│ privateKeyPem já está em PKCS#8 cifrado com AES-256-CBC+passphrase
├─ crypto.encrypt(privateKeyPem) → "iv1:priv_cipher"
├─ crypto.encrypt(passphrase) → "iv2:pass_cipher"
├─ Persiste: encryptionPrivateKeyEncrypted, encryptionPassphraseEncrypted, encryptionPublicKey
└─ syncWhatsAppEncryptionCache(number, crypto)
├─ crypto.decrypt(encryptionPrivateKeyEncrypted) → PKCS#8 PEM
├─ crypto.decrypt(encryptionPassphraseEncrypted) → passphrase
└─ setWaEncryption(phoneNumberId, { privateKeyPem, passphrase })
→ Redis: wa:encryption:{phoneNumberId}
flow-ai-meta-api (ao receber webhook de WhatsApp Flow):
└─ getWaEncryption(phoneNumberId) ← Redis
├─ Carrega private key PEM com passphrase (Node.js native)
└─ Decifra payload com RSA + AES-GCM (protocolo da Meta)App secret do webhook (validação HMAC)
PATCH /routers/:id { metaAppSecret: "abc123..." }
└─ RouterService
├─ crypto.encrypt("abc123...") → "iv:cipher"
├─ Persiste: metaAppSecretEncrypted = "iv:cipher"
└─ syncWhatsAppWebhookCaches()
├─ crypto.decrypt(metaAppSecretEncrypted) → "abc123..."
└─ setWaWebhook(phoneNumberId, { appSecret: "abc123..." })
→ Redis: wa:webhook:{phoneNumberId}
flow-ai-meta-api (ao receber webhook POST):
└─ getWaWebhook(phoneNumberId) ← Redis
└─ HMAC-SHA256(payload, appSecret) === X-Hub-Signature-256Fronteira de criptografia
┌──────────────────────────────────────────────────────────────────────┐
│ flow-ai-core (única peça que conhece CRYPTO_SECRET) │
│ │
│ Postgres ──── crypto.decrypt() ──→ Redis (plaintext) │
│ (iv:cipher) (JSON strings) │
│ │ │
│ Input ──── crypto.encrypt() ──→ Postgres │ │
│ (plaintext) (iv:cipher) │ │
└──────────────────────────────────────────────────────────────────────┘
│
┌────────────────────────┘
↓
┌─────────────────────────────┐
│ flow-ai-meta-api │ ← só Redis (sem Prisma)
│ flow-ai-ig-api │ ← só Redis (sem Prisma)
│ flow-ai-engine │
│ flow-ai-agent │ ← leem segredos já
│ flow-ai-orchestrator │ decifrados do Redis;
│ flow-ai-webchat-gateway │ nunca conhecem CRYPTO_SECRET,
└─────────────────────────────┘ nunca decifram campos *Encrypted
engine,agent,orchestratorewebchat-gatewayusam Prisma para outras entidades (mensagens, sessões, flows), mas nunca leem as colunas*Encryptednem detêmCRYPTO_SECRET.meta-apieig-apisão os únicos totalmente sem Postgres. A invariante é não decifrar, não não tocar o banco.
Nota: o
flow-ai-webchat-gatewayfaz seu próprio bcrypt dosessionKey(hash unidirecional, semCRYPTO_SECRET) — é a única exceção à regra "cripto só no core", e ainda assim não é criptografia simétrica reversível.
Por que Redis em plaintext se o Postgres está cifrado?
O Redis é memória volátil e efêmera — se reiniciar, os dados somem e o warm-up os repõe. O Postgres é o estado durável. O vetor de risco que a criptografia no banco mitiga é o vazamento do armazenamento persistente: backups, dumps de auditoria, acesso indevido ao disco. O Redis é um cache interno sem exposição externa direta. Cifrar no Redis também adicionaria latência de decrypt em toda leitura — sem ganho prático dado o modelo de ameaça.
Decifração de payload WhatsApp Flow (bônus)
O protocolo da Meta para WhatsApp Flows usa um esquema híbrido RSA + AES-GCM, implementado em packages/flow-ai-whatsapp-flow-engine/src/crypto.ts:
Webhook chega com { encrypted_aes_key, encrypted_flow_data, initial_vector }
├─ RSA-OAEP-SHA256.decrypt(encrypted_aes_key, privateKey) → aes_key (16 bytes)
├─ AES-128-GCM.decrypt(encrypted_flow_data, aes_key, initial_vector) → payload JSON
└─ AES-128-GCM.encrypt(response, aes_key, flipped_iv) → encrypted responseA chave RSA privada vem do cache wa:encryption:{phoneNumberId}, que o flow-ai-meta-api lê do Redis. O flow-ai-core nunca participa da execução de tempo real — só provisionou a chave.
Arquivos relevantes
| Arquivo | Papel |
|---|---|
services/flow-ai-core/src/common/crypto.ts | Crypto.encrypt(), Crypto.decrypt(), generateRsaKeyPair(), generateRsaPassphrase() |
services/flow-ai-core/src/config/auth.ts | Configuração do CRYPTO_SECRET |
services/flow-ai-core/src/services/whatsapp-number.service.ts | Cifragem/decifragem do accessToken e sync de cache |
services/flow-ai-core/src/services/whatsapp-number-encryption.service.ts | Geração e gestão das chaves RSA |
services/flow-ai-core/src/services/whatsapp-encryption-cache.ts | Sync wa:encryption:{phoneNumberId} |
services/flow-ai-core/src/services/whatsapp-webhook-cache.ts | Sync wa:webhook:{phoneNumberId} (app secret) |
services/flow-ai-core/src/services/router.service.ts | Cifragem do metaAppSecret |
services/flow-ai-core/src/http/routes/router-variables.routes.ts | Cifragem de variáveis secretas do Router (vars:router:{routerId}) |
services/flow-ai-core/src/http/routes/flow-variables.routes.ts | Cifragem de variáveis secretas do Flow (vars:flow:{flowId}) |
services/flow-ai-core/src/services/llm-credential.service.ts | Decifragem da apiKey LLM → llm:credential:{credentialId} |
services/flow-ai-core/src/services/observability-settings.service.ts | Decifragem do logfireToken → observability:config |
services/flow-ai-core/src/services/instagram-account.service.ts | Cifragem/decifragem de accessToken + igAppSecret do Instagram |
services/flow-ai-core/src/http/routes/integration.routes.ts | Cifragem de credentials de integração (decifra sob demanda, sem cache) |
packages/flow-ai-whatsapp-flow-engine/src/crypto.ts | Decifração RSA+AES-GCM do payload de WhatsApp Flow |
packages/flow-ai-database/prisma/schema.prisma | Definição de todos os campos *Encrypted |