Skip to content

[types] resetAccount descarta a DEK e deixa feedback.message cifrado com a chave antiga — o /admin inteiro cai em 500 e as mensagens ficam ilegíveis para sempre #121

Description

@Guiroos

Onde

lib/actions/reset-account.ts:66-123 — a função inteira; o ponto exato é :89 (delete de userSettings) combinado com :99 (provisionamento de DEK nova).

Consumidor que quebra: lib/queries/admin.ts:49-67app/(app)/admin/page.tsx:46.

Evidência

resetAccount apaga userSettings — que é onde mora encryptedDek — dentro da transaction da fase 1:

// lib/actions/reset-account.ts:89
await tx.delete(userSettings).where(eq(userSettings.userId, userId))

…e a fase 2 provisiona uma DEK nova:

// :98-99
// Phase 2: Provision new DEK (userSettings was deleted — getDekForUser creates a fresh row)
const dek = await getDekForUser(userId)

A DEK não é derivada de nada — é aleatória (lib/crypto/keys.ts:21-23):

export function generateDek(): Buffer {
  return randomBytes(KEY_LEN)
}

O COALESCE de getDekForUser (keys.ts:57) só preserva DEK de uma linha que existe. Como a fase 1 apagou a linha, o INSERT grava a chave nova e a antiga desaparece — não há cópia em lugar nenhum.

O problema é o que sobra. A fase 1 apaga 16 tabelas. Comparando com as 21 pgTable do schema, feedback é a única tabela com coluna cifrada por DEK que sobrevive ao reset:

// lib/db/schema.ts:538-548
export const feedback = pgTable('feedback', {
  userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
  message: text('message').notNull(),   // cifrado — lib/actions/feedback.ts:28
  ...
})
// lib/actions/feedback.ts:28
message: encryptField(data.message, dek),

Depois do reset, essas linhas continuam cifradas com a DEK que foi destruída. Aí entra o contrato que mente:

// lib/queries/admin.ts:49-67 — a assinatura devolve `message: string` (texto claro)
const getDecryptedMessage = async (userId: string, message: string) => {
  if (!dekMap.has(userId)) dekMap.set(userId, await getDekForUser(userId))
  return decryptField(message, dekMap.get(userId)!)     // :54
}
const decrypted = await Promise.all(rows.map(async (row) => ({ ... })))  // :57

decryptField não degrada: decipher.final() (lib/crypto/fields.ts:24) lança quando o auth tag do GCM não confere. Um único throw dentro do Promise.all de :57 rejeita a query inteira, e app/(app)/admin/page.tsx:46 a chama dentro de outro Promise.all — a página cai no app/(app)/error.tsx.

O tipo é verdade sintática e mentira semântica: getAllFeedbacks(): Promise<{ message: string, ... }[]> promete texto claro. O contrato real, não escrito em lugar nenhum, é "a DEK atual do usuário é a mesma que cifrou esta linha" — e resetAccount é exatamente o código que rompe essa invariante, sem que nada no compilador ou no schema o registre.

Verificações feitas (tentativa de falsificar o achado)

1. Rodei o crypto real (node, sem node_modulesfields.ts usa só node:crypto), reproduzindo encryptField/decryptField byte a byte:

controle (mesma DEK): Adoraria um app nativo
DEK nova -> THROW: Unsupported state or unable to authenticate data
Promise.all REJEITA -> Unsupported state or unable to authenticate data

O controle com a mesma DEK devolve o texto; o Promise.all rejeita de fato, não resolve com undefined.

2. Checagem que mataria o achado: a DEK é determinística? Se getDekForUser derivasse a chave de userId + MEK, o reset devolveria a mesma DEK e nada quebraria. Não é o caso — keys.ts:22 é randomBytes(32), e o COALESCE de :57 não alcança uma linha que acabou de ser deletada. O achado sobreviveu aqui.

3. Enumerei as 21 tabelas do schema contra as 16 que a fase 1 apaga. Sobrevivem: users, accounts, sessions, verificationTokens (NextAuth, nenhuma coluna cifrada por DEK) e feedback. Achado de site único, não multi-sitepeople.email, investments.notes, userSettings.pixKey e as demais colunas cifradas estão todas em tabelas que a fase 1 apaga.

4. O outro caminho destrutivo do repo faz o certo — e foi documentado fazendo o certo. deleteAccount apaga a linha de users, e feedback.userId é onDelete: 'cascade' (schema.ts:540-542), então o feedback vai junto. O docs/seo-landing-backlog.md §6.1 chega a listar isso explicitamente ("incluindo accounts, sessions, feedback e people") e no parágrafo seguinte contrasta com o resetAccount — sem notar a lacuna. Ou seja: quando alguém raciocinou sobre feedback num caminho destrutivo, a conclusão foi que ele é dado de usuário e precisa sair junto. resetAccount é a minoria, não uma decisão.

5. Convenção/documentação: .claude/domain.md § "Reset de Conta" descreve as 3 fases e a ordem de delete ditada por FK, e não menciona feedback em momento nenhum — enquanto a § "Feedback" do mesmo arquivo registra que feedback.message é cifrado pela DEK do usuário. Nada em .claude/*.md ou em docs/ diz que o feedback deve sobreviver ao reset.

6. git log — inconclusivo, e digo isso em vez de fingir que confirmou. O histórico deste clone está compactado nesse trecho (git log -- lib/db/schema.ts devolve 3 commits; --follow --diff-filter=A em reset-account.ts não alcança o commit de criação). Não deu para ver o commit que escreveu a lista de deletes nem se feedback já existia então. A falsificação de "foi deliberado" se apoia nos itens 4 e 5, não no histórico.

Impacto

Quem clica em "Resetar conta" no SettingsDialog (components/settings/SettingsDialog.tsx:55) tendo enviado feedback antes. Enviar feedback é uma ação de baixa fricção e onipresente — o FeedbackDialog está montado tanto na Sidebar:257 quanto no BottomNav:261, ou seja, em toda tela autenticada. Resetar a conta é a ação natural de quem testou o app e quer começar do zero — exatamente o perfil de beta que mais manda feedback.

Duas consequências, ambas silenciosas no momento em que acontecem:

  1. Perda de dado permanente e irrecuperável. As mensagens continuam na tabela, mas a chave que as abre foi destruída. Não há backfill possível: scripts/encrypt-existing-data.ts sabe reparar plaintext e valor já cifrado, não ciphertext órfão. O usuário não percebe (não há tela onde ele leia o próprio feedback) e o admin também não, até abrir o /admin.

  2. O /admin inteiro para de funcionar — não só as linhas afetadas. getAllFeedbacks não isola linha: um throw no Promise.all de admin.ts:57 derruba a query, e page.tsx:46 a espera junto com getAdminStats(). O admin vê "algo deu errado" no lugar da caixa de entrada de feedback inteira, de todos os usuários, para sempre. Basta um usuário resetar a conta para o canal de feedback do produto ficar inacessível — e não há como se recuperar pela UI, só apagando as linhas órfãs direto no Neon.

Agrava que feedback é justamente o canal que o /admin existe para servir: o modo de falha remove a única superfície onde o problema seria notado.

Cobertura

Não existe teste que pegue isso, e vale registrar o que o teste existente está garantindo, porque ele dá falsa confiança: __tests__/integration/actions-reset-account.test.ts:42 ("apaga todos os dados financeiros do usuário") enumera 12 tabelas e assere toHaveLength(0) em todas. Ele não afirma o bug (não há asserção errada para corrigir) — é simplesmente cego a ele: nunca cria linha em feedback e nunca decripta nada depois do reset. Passa hoje e continuaria passando com o defeito intacto.

Caso novo, no mesmo arquivo (já tem a maquinaria de branch Neon + DEK real):

it('mantém o feedback legível depois do reset', async () => {
  const dekAntiga = await getDekForUser(userId)
  await db.insert(schema.feedback).values({
    userId, category: 'melhoria', page: '/dashboard',
    message: encryptField('Adoraria um app nativo', dekAntiga),
  })

  await resetAccount()

  const [row] = await db.select().from(schema.feedback).where(eq(schema.feedback.userId, userId))
  const dekNova = await getDekForUser(userId)          // ver a armadilha do cache() abaixo
  expect(decryptField(row.message, dekNova)).toBe('Adoraria um app nativo')
})

A asserção precisa ser toBe('<texto original>'), não not.toThrow(). É isso que separa a correção certa das duas erradas mais prováveis:

  • fix que simplesmente inclui feedback na fase 1: a linha some, row é undefined, o teste quebra — que é o resultado desejado, porque apagar o feedback resolve o 500 destruindo o dado que o /admin existe para ler;
  • fix que só põe try/catch em getAllFeedbacks: devolve o sentinela, não o texto, e o teste quebra.

Um expect(...).not.toThrow() passaria nas duas e não cobriria nada.

⚠️ O teste precisa rodar em it() separado do primeiroactions-reset-account.test.ts já compartilha userId entre os testes e chama resetAccount() no primeiro it, então criar a linha de feedback dentro dele contaminaria as asserções de toHaveLength(0).

Proposta

Recifrar, não apagar. As duas intenções em jogo são legítimas e não conflitam: a rotação de DEK existe para cripto-apagar, e o feedback é dado de produto que o admin precisa continuar lendo. Recifrar atende as duas.

// ANTES da fase 1 — leitura direta de userSettings, NÃO via getDekForUser
const [settings] = await db
  .select({ encryptedDek: userSettings.encryptedDek })
  .from(userSettings)
  .where(eq(userSettings.userId, userId))
const dekAntiga = settings?.encryptedDek ? decryptDek(settings.encryptedDek) : null

const pendentes = dekAntiga
  ? await db.select({ id: feedback.id, message: feedback.message })
      .from(feedback).where(eq(feedback.userId, userId))
  : []

// ... fase 1 (delete) e fase 2 (`const dek = await getDekForUser(userId)`) inalteradas ...

// fase 3, junto do seed:
for (const f of pendentes) {
  await db.update(feedback)
    .set({ message: encryptField(decryptField(f.message, dekAntiga!), dek) })
    .where(eq(feedback.id, f.id))
}

Helper nomeado, e por que não o óbvio

Usar decryptDek de lib/crypto/keys.ts:34, lendo userSettings.encryptedDek direto — nunca getDekForUser para capturar a DEK antiga. Este é o ponto que decide se o PR conserta ou destrói a conta:

getDekForUser é embrulhado em cache() do React (keys.ts:47), que memoiza por request. Uma Server Action é um request. Se a correção chamar getDekForUser(userId) no topo para pegar a DEK antiga, a chamada da fase 2 (:99) devolve o mesmo Buffer memoizado — a DEK deletada. As categorias padrão da fase 3 seriam então cifradas com uma chave que não existe mais no banco, e o /dashboard, o /registro e o /categorias do usuário passariam a estourar Unsupported state or unable to authenticate data em todo load. O reset deixaria a conta pior do que este bug deixa o /admin.

getDekForUser no topo é o caminho natural — é o helper que todo o resto do repo usa. Aqui ele é exatamente o errado.

decryptField/encryptField (e não decryptOptional/encryptOptional) porque feedback.message é .notNull() (schema.ts:544) — decryptOptional só serviria para mascarar um null que o schema não permite.

Segunda metade: getAllFeedbacks não deve cair por causa de uma linha

Independente da correção acima, lib/queries/admin.ts:54 precisa degradar por linha em vez de derrubar a página — as linhas já órfãs em produção continuam órfãs depois do fix, e nenhum reset futuro deve poder tirar o /admin do ar:

try {
  return decryptField(message, dek)
} catch (err) {
  console.error('[getAllFeedbacks] mensagem ilegível', { feedbackId, err })
  return '[mensagem ilegível — chave rotacionada]'
}

O console.error é o que hoje não existe: sem ele, a linha órfã não deixa rastro em lugar nenhum (o projeto não tem Sentry).

Ordem de execução sugerida: o try/catch primeiro, sozinho, porque destrava o /admin de imediato para eventuais linhas já órfãs; a recifragem em seguida, que é a correção de verdade.

Custo estimado

M (3 arquivos): lib/actions/reset-account.ts, lib/queries/admin.ts, __tests__/integration/actions-reset-account.test.ts.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    claude-auditAchado da auditoria automáticaclaude-wipJá tem PR aberto, não pegar de novotypes

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions