DEV Community

BielCoelho
BielCoelho

Posted on

Como o brtech implementa anonimato de verdade: a arquitetura de privacidade por tras dos relatos

Se eu sofrer um ataque e dumparem o banco de dados do brtech, o que o invasor encontra?

Hashes irreversiveis. Nomes aleatorios. Tokens nulos. Zero emails. Zero IPs. Zero user agents.

Nao e "confia que a gente nao vai vazar". E "mesmo que vaze, nao tem o que entregar".

Esse post conta como chegamos nessa arquitetura - e os 6 dias de commits que transformaram uma solucao ingenua em anonimato de verdade.


Dia 1: "So guardar o hash, ne?"

27 de junho. Primeiro commit do auth.ts. O plano parecia simples:

// Solucao ingenua - Dia 1
databaseHooks: {
  user: {
    create: {
      before: async (user) => {
        const hash = hmac(user.email)
        const existing = await prisma.user.findUnique({ where: { emailHash: hash } })
        return {
          data: {
            ...user,
            emailHash: hash,
            email: null, // "pronto, nao guardei email"
          },
        }
      },
    },
  },
}
Enter fullscreen mode Exit fullscreen mode

Funcionava? Tecnicamente sim. O email chegava no hook, era hasheado, e o campo email ia como null para o banco.

Mas tinha um problema. O email ainda transitava pelo hook do Better Auth. A lib recebia o payload do Google/GitHub/LinkedIn com o email em texto puro, chamava o before, e so depois descartava. Se alguem logasse o user dentro do hook - ou se o Better Auth fizesse algum log interno - o email estava la.

Era melhor que guardar no banco. Mas nao era anonimato de verdade.


Dia 2: "anonymousId publico e ma ideia"

28 de junho. O schema tinha um campo anonymousId - 4 caracteres alfanumericos que identificavam o usuario publicamente nos perfis.

model User {
  anonymousId String? @unique
  // ...
}
Enter fullscreen mode Exit fullscreen mode

A ideia era: em vez de mostrar o CUID interno (que e imutavel e poderia ser usado para correlacionar dados entre tabelas), mostramos um ID publico curto.

29 de junho. Removido. O commit ab781c3 deletou o campo do schema, do auth, dos tipos, de tudo.

O problema: qualquer ID estavel e visivel publicamente permite correlacionar contribuicoes ao longo do tempo. Se o usuario aB3x posta um salario na Stone e amanha posta uma review no Nubank, da para juntar as pecas - especialmente se o cargo e senioridade forem parecidos.

Nao ter ID publico nenhum tambem nao funcionava - o usuario precisa ver seu proprio historico. A solucao final foi o anonymousId virar um derivado do emailHash (visivel so para o proprio usuario logado), mas deixamos isso para depois.


Dia 3: "Usuario Anonimo com foto de dado"

2 de julho. Commit b2e1f13. Reescrevemos o before hook para nunca mais deixar o email original tocar campos que vao para o banco:

const generateRandomEmail = () => `${nanoid(16)}@anonimizado.brtech.fyi`
const generateName = () => `Anonimo #${nanoid(7)}`
const generateImage = () => `https://api.dicebear.com/6.x/initials/svg?seed=${nanoid(7)}`

user: {
  create: {
    before: async (user) => ({
      data: {
        ...user,
        emailHash: hashEmail(user.email),  // unica coisa que persiste
        email: generateRandomEmail(),       // falso
        name: generateName(),               // "Anonimo #aB3xK2m"
        image: generateImage(),             // avatar DiceBear aleatorio
        emailVerified: false,
        seniorityLevel: null,
        currentRole: null,
      },
    }),
  },
}
Enter fullscreen mode Exit fullscreen mode

Agora o fluxo era:

  1. Google manda { email: "joao@gmail.com", name: "Joao Silva", image: "https://..." }
  2. O hook gera emailHash via HMAC-SHA256
  3. Substitui email, name, image por dados falsos
  4. O banco nunca ve o email real

Mas ainda faltava uma camada. Os tokens OAuth (access token, refresh token) e os dados de sessao (IP, user agent) continuavam sendo persistidos pelo Better Auth automaticamente. Esses tokens sao a chave para acessar a conta Google/GitHub do usuario - se vazarem, o invasor pode se passar por ele.


Dia 4: O lockdown (o commit que mudou tudo)

5 de julho. Commit 32ebbbc. O "privacy lockdown".

Duas descobertas aconteceram nesse dia:

1. O Better Auth persiste tokens OAuth por padrao. Cada login guarda accessToken, refreshToken, idToken, scope na tabela Account. Esses tokens dao acesso a API do Google/GitHub/LinkedIn em nome do usuario.

2. O Better Auth atualiza os tokens em TODO login. O campo account.updateAccountOnSignIn: true (padrao) reescreve os dados da conta a cada sessao.

A solucao foi atacar em todas as frentes:

advanced: {
  ipAddress: {
    disableIpTracking: true,  // nunca armazena IP
  },
},

account: {
  // Nao atualiza tokens a cada login
  updateAccountOnSignIn: false,

  create: {
    before: async (account) => ({
      data: {
        ...account,
        accessToken: null,
        refreshToken: null,
        idToken: null,
        accessTokenExpiresAt: null,
        refreshTokenExpiresAt: null,
        scope: null,
        password: null,
      },
    }),
    after: async (account) => {
      // Segunda camada: zera email e imagem do User
      // apos o Better Auth terminar de criar a conta
      await prisma.user.update({
        where: { id: account.userId },
        data: {
          email: null,
          emailVerified: false,
          image: null,
        },
      })
    },
  },
  update: {
    before: async (account) => ({
      data: {
        ...account,
        accessToken: null,
        refreshToken: null,
        // ... mesmo tratamento
      },
    }),
  },
},

session: {
  create: {
    before: async (session) => ({
      data: {
        ...session,
        ipAddress: null,   // nunca armazena IP
        userAgent: null,   // nunca armazena user agent
      },
    }),
  },
  update: {
    before: async (session) => ({
      data: {
        ...session,
        ipAddress: null,
        userAgent: null,
      },
    }),
  },
},

user: {
  update: {
    before: async (userData) => ({
      data: {
        ...userData,
        email: generateRandomEmail(),   // re-randomiza a cada update
        emailVerified: false,
      },
    }),
  },
},
Enter fullscreen mode Exit fullscreen mode

O after do account e a jogada mais sutil aqui. Mesmo depois do before limpar os campos, o Better Auth pode popular novamente o User.email e User.image durante a criacao da conta. O after garante que, depois que a lib termina seu trabalho, a gente limpa de novo.

Nesse mesmo dia criei o PRIVACY_DATA_INVENTORY.md - um documento mapeando campo por campo do banco, o que contem, e por que pode ou nao ser anonimizado. Virou a referencia canonica para qualquer decisao futura sobre privacidade.


Dia 5: Cross-provider linking sem email

6 de julho. Commits 5183060673df35c7c2b3d.

Agora o banco nao tinha email. Beleza. Mas e o cenario onde o usuario faz login com Google num dia e com GitHub no outro?

Sem email no banco, o Better Auth nao consegue correlacionar as contas. Ele faz findOne(User, { email: "joao@gmail.com" }) e nao acha nada - porque o email no banco e aB3xK2m9nQpL4rSt@anonimizado.brtech.fyi.

A solucao: interceptar as queries do adapter.

O Better Auth usa um adapter de banco de dados para todas as operacoes. Se eu conseguisse interceptar as chamadas findOne no model User filtrando por email, poderia redirecionar para emailHash:

export function withEmailHashLookup(createAdapter) {
  return (...args) => {
    const factory = createAdapter(...args)
    return (options) => wrap(factory(options))
  }
}

function wrap(adapter) {
  return {
    ...adapter,
    findOne: async ({ model, where, select, join }) => {
      // Intercepta queries de User por email
      if (
        model.toLowerCase() === 'user' &&
        where?.length === 1 &&
        where[0]?.field === 'email' &&
        typeof where[0]?.value === 'string'
      ) {
        // Tenta achar por emailHash primeiro
        const hashResult = await adapter.findOne({
          model,
          where: [{ field: 'emailHash', value: hashEmail(where[0].value) }],
          select,
          join,
        })
        if (hashResult) return hashResult
      }
      // Fallback: busca normal por email
      return adapter.findOne({ model, where, select, join })
    },
    // O wrapper tambem precisa funcionar dentro de transacoes
    transaction: async (cb) => {
      return adapter.transaction(async (trx) => cb(wrap(trx)))
    },
  }
}
Enter fullscreen mode Exit fullscreen mode

E na configuracao do auth:

accountLinking: {
  enabled: true,
  trustedProviders: ['google', 'github', 'linkedin', 'microsoft'],
  allowDifferentEmails: false,
  updateUserInfoOnLink: false,
},
Enter fullscreen mode Exit fullscreen mode

Agora o fluxo e:

  1. Usuario faz login com Google → emailHash gerado, conta criada
  2. Usuario faz login com GitHub (mesmo email) → o adapter intercepta a query por email, traduz para emailHash, acha o mesmo usuario
  3. A conta GitHub e linkada ao mesmo usuario sem que o email jamais toque o banco

Bug sutil que quase passou: o adapter usava model === 'User' (case-sensitive) mas o Better Auth internamente chama com 'user' (lowercase) em alguns metodos. Corrigi no commit c7c2b3d mudando para model.toLowerCase() === 'user'. Isso custou uma hora de debug.

Gotcha: pensando agora, nem precisava desse adapter todo. Era so gerar o hash e fazer append de @anonymized.brtech.fyi como email fake - o Better Auth faria o lookup por email normalmente e encontraria o usuario pelo hash deterministico. Sem adapter. Sem wrapper. Sem interceptar query. Mas tambem nao tenho certeza se funcionaria com account linking cross-provider... e ja que ta funcionando assim, vai ficar assim.


Dia 6: Soft delete que apaga ate o hash

Uma vez por mes alguem pede para deletar a conta. A LGPD exige isso.

Mas se eu so marco status: 'deleted', o hash continua la. E hashes podem ser atacados por forca bruta se o espaco de busca for pequeno (ex: "quero saber se joao@gmail.com tem conta no brtech" → gero o hash e comparo).

A solucao: sobrescrever o hash com lixo:

await tx.user.update({
  where: { id: session.id },
  data: {
    status: 'deleted',
    deletedAt: now,
    emailHash: `deleted-${nanoid(32)}`,  // hash original e destruido
  },
})
Enter fullscreen mode Exit fullscreen mode

As contribuicoes (salarios, reviews, entrevistas) nao sao deletadas - elas permanecem anonimas, alimentando as medias e rankings. Mas o ponteiro entre elas e o usuario some para sempre.

O que acontece se o usuario criar uma conta nova depois? Vai gerar um emailHash novo, conta nova, historico zerado. A conta antiga e um fantasma - contribuicoes orfas que nao apontam para ninguem.


O que essa arquitetura NAO protege

Para ser honesto, tem limites:

Fingerprinting comportamental. Se voce postar "Sou Senior na Stone, entrei em 2023, ganho R$18k CLT" e amanha tuitar exatamente a mesma frase, a correlacao e externa ao brtech. Nao tem como impedir que o usuario se auto-identifique fora da plataforma.

Metadados de moderacao. Moderadores veem o conteudo das contribuicoes (precisam revisar). Nao veem identidade - mas veem que o usuario Anonimo #aB3xK2m postou review X e salario Y. Se o usuario usar o mesmo cargo e empresa em ambas, e um padrao visivel.

Rotacao do BETTER_AUTH_SECRET. Se eu trocar a chave do HMAC, todos os hashes mudam. Ninguem consegue fazer login na conta antiga. Isso e documentado: rotacionar o secret requer uma migracao planejada que recalcula todos os emailHash do banco. Nao e operacao de emergencia.


A funcao que amarra tudo

No final, 14 linhas de codigo seguram o castelo:

// src/lib/hash-email.ts
export function hashEmail(email: string): string {
  const secret = process.env.BETTER_AUTH_SECRET
  if (!secret) throw new Error('BETTER_AUTH_SECRET is required')
  return crypto
    .createHmac('sha256', secret)
    .update(email.toLowerCase().trim())
    .digest('hex')
}
Enter fullscreen mode Exit fullscreen mode

Deterministico. Irreversivel. Sem sal (o secret e o sal). A mesma funcao e usada em 4 lugares: auth hook, adapter, seed script, e representative lookup. Se algum lugar usar um algoritmo diferente, usuarios duplicados aparecem.

E essa funcao que me deixa dormir tranquilo. Se um dia alguem pedir acesso ao banco com um mandado judicial, o que vou entregar sao hashes HMAC-SHA256, nomes Anonimo # aleatorios, e tokens nulos.

E salarios que nao apontam para ninguem.


O codigo esta no ar. A versao de preview em dev.brtech.fyi tem dados ficticios para testar. A versao oficial em brtech.fyi já tem algumas contribuições, com dados reais.

O repo e aberto: github.com/tuf/brtech (ou onde estiver hospedado).

Top comments (1)

Collapse
 
voltagegpu profile image
VoltageGPU

Intéressant, l'anonymat réel dans les systèmes de publication de témoignages est un défi technique complexe. Une solution pourrait inclure l'utilisation de circuits à seuil (threshold circuits) ou de compute enclavement comme Intel SGX, pour traiter les données sans les exposer en clair — VoltageGPU pourrait aider là aussi, mais ce n’est pas indispensable dans tous les cas.