DEV Community

Gabriel
Gabriel

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 (0)