Skip to content

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 sessionKey do WebChat roda fora do flow-ai-core, no flow-ai-webchat-gateway. É um hash unidirecional que não usa CRYPTO_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

CampoAlgoritmoConteúdoCache Redis
accessTokenEncryptedAES-256-CBCToken de acesso Meta Cloud APIwa:credentials:{phoneNumberId}
encryptionPrivateKeyEncryptedAES-256-CBC (sobre PKCS#8+AES)Chave privada RSA-2048 em PEMwa:encryption:{phoneNumberId}
encryptionPassphraseEncryptedAES-256-CBCPassphrase que protege a chave privadawa:encryption:{phoneNumberId}
encryptionPublicKeyplaintextChave 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

CampoAlgoritmoConteúdoCache Redis
metaAppSecretEncryptedAES-256-CBCApp secret para validação HMAC-SHA256wa:webhook:{phoneNumberId}
metaVerifyTokenplaintextToken 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

CampoCondiçãoAlgoritmoCache Redis
valueisSecret = falseplaintextvars:router:{routerId} / vars:flow:{flowId}
valueEncryptedisSecret = trueAES-256-CBCvars: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

CampoAlgoritmoConteúdoCache Redis
apiKeyEncryptedAES-256-CBCAPI 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

CampoAlgoritmoConteúdoCache Redis
logfireTokenEncryptedAES-256-CBCWrite 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

CampoAlgoritmoConteúdoCache Redis
accessTokenEncryptedAES-256-CBCToken de acesso da Instagram Graph APIig:credentials:{igUserId}
igAppSecretEncryptedAES-256-CBCApp 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-apiig:credentials:* (outbound Graph API) e ig:webhook:* (validação de assinatura) do Redis. O igAppSecret é distinto do app secret WA do Router.

integrations

CampoAlgoritmoConteúdoCache Redis
credentialsEncryptedAES-256-CBCToken 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

CampoAlgoritmoConteúdo
passwordbcrypt (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:

ModeloCampo cifradoO que o cliente recebe
WhatsAppNumberaccessTokenEncryptedNada — omitido em WhatsAppNumberPublic
InstagramAccountaccessTokenEncryptedNada — omitido em InstagramAccountPublic
RoutermetaAppSecretEncryptedhasMetaAppSecret: boolean (presença, não valor)
RoutermetaVerifyTokenhasMetaVerifyToken: boolean (mesmo padrão, embora em claro no banco)
RouterVariable / FlowVariablevalueEncryptedvalue: null quando isSecret=true
LlmCredentialapiKeyEncryptedNada — campo omitido do tipo de resposta
IntegrationcredentialsEncryptedhasCredentials: boolean (via toPublic manual)
ObservabilitySettingslogfireTokenEncryptedhasToken: boolean (presença, não valor)
UserpasswordNada — 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 response schema 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 declaram response schema, a serialização é um JSON.stringify direto 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 dedicado GET /whatsapp/numbers/:id/encryption já expõe apenas hasPrivateKey: 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ão whatsapp_numbers:write, respeita escopo de router (RBAC) e emite um logger.warn a 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-256

Fronteira 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, orchestrator e webchat-gateway usam Prisma para outras entidades (mensagens, sessões, flows), mas nunca leem as colunas *Encrypted nem detêm CRYPTO_SECRET. meta-api e ig-api são os únicos totalmente sem Postgres. A invariante é não decifrar, não não tocar o banco.

Nota: o flow-ai-webchat-gateway faz seu próprio bcrypt do sessionKey (hash unidirecional, sem CRYPTO_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 response

A 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

ArquivoPapel
services/flow-ai-core/src/common/crypto.tsCrypto.encrypt(), Crypto.decrypt(), generateRsaKeyPair(), generateRsaPassphrase()
services/flow-ai-core/src/config/auth.tsConfiguração do CRYPTO_SECRET
services/flow-ai-core/src/services/whatsapp-number.service.tsCifragem/decifragem do accessToken e sync de cache
services/flow-ai-core/src/services/whatsapp-number-encryption.service.tsGeração e gestão das chaves RSA
services/flow-ai-core/src/services/whatsapp-encryption-cache.tsSync wa:encryption:{phoneNumberId}
services/flow-ai-core/src/services/whatsapp-webhook-cache.tsSync wa:webhook:{phoneNumberId} (app secret)
services/flow-ai-core/src/services/router.service.tsCifragem do metaAppSecret
services/flow-ai-core/src/http/routes/router-variables.routes.tsCifragem de variáveis secretas do Router (vars:router:{routerId})
services/flow-ai-core/src/http/routes/flow-variables.routes.tsCifragem de variáveis secretas do Flow (vars:flow:{flowId})
services/flow-ai-core/src/services/llm-credential.service.tsDecifragem da apiKey LLM → llm:credential:{credentialId}
services/flow-ai-core/src/services/observability-settings.service.tsDecifragem do logfireTokenobservability:config
services/flow-ai-core/src/services/instagram-account.service.tsCifragem/decifragem de accessToken + igAppSecret do Instagram
services/flow-ai-core/src/http/routes/integration.routes.tsCifragem de credentials de integração (decifra sob demanda, sem cache)
packages/flow-ai-whatsapp-flow-engine/src/crypto.tsDecifração RSA+AES-GCM do payload de WhatsApp Flow
packages/flow-ai-database/prisma/schema.prismaDefinição de todos os campos *Encrypted

flow-ai — plataforma proprietária de atendimento via WhatsApp