/** * One-shot backfill: compute `phoneHash` + `emailHash` for User rows * that have phone/email but no hash (legacy seed data from before the * privacy hashing layer was introduced). Without hash, the auth flow * `findUnique({ phoneHash })` returns null → "Số điện thoại hoặc mật * khẩu không đúng" even when password is correct. * * Usage: * NODE_OPTIONS="-r dotenv/config" DOTENV_CONFIG_PATH=.env \ * FIELD_ENCRYPTION_KEY= \ * pnpm tsx scripts/backfill-user-pii-hashes.ts [--dry-run] * * Hash format MUST match `FieldEncryptionService.computeHash`: * hmacKey = HKDF-SHA256(primaryKey, "", "goodgo-field-hash", 32) * phoneHash = HMAC-SHA256(hmacKey, value.toLowerCase().trim()) */ import 'dotenv/config'; import * as crypto from 'node:crypto'; import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaClient } from '@prisma/client'; import pg from 'pg'; const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] }); const adapter = new PrismaPg(pool); const prisma = new PrismaClient({ adapter }); const dryRun = process.argv.includes('--dry-run'); const primaryKey = process.env['FIELD_ENCRYPTION_KEY'] ?? process.env['KYC_ENCRYPTION_KEY']; if (!primaryKey) { console.error( '✗ FIELD_ENCRYPTION_KEY (or KYC_ENCRYPTION_KEY) must be set so the hash matches the cluster API.', ); process.exit(1); } const hmacKey = crypto.hkdfSync( 'sha256', Buffer.from(primaryKey, 'hex'), Buffer.alloc(0), Buffer.from('goodgo-field-hash', 'utf8'), 32, ) as unknown as Buffer; function hash(value: string): string { return crypto .createHmac('sha256', hmacKey) .update(value.toLowerCase().trim()) .digest('hex'); } async function main(): Promise { // Re-hash ALL users — earlier backfill used plain SHA-256 instead of // HMAC-SHA256, so even rows that already have a hash need to be // recomputed before they match the runtime API's lookup. const users = await prisma.user.findMany({ select: { id: true, phone: true, email: true, phoneHash: true, emailHash: true }, }); console.log(`🔍 Found ${users.length} users missing phoneHash or emailHash:`); for (const u of users) { console.log( ` ${u.id} phone=${u.phone} email=${u.email ?? '—'} phoneHash=${ u.phoneHash ? 'set' : 'MISSING' } emailHash=${u.emailHash ? 'set' : 'MISSING'}`, ); } if (dryRun) { console.log('💡 --dry-run: no writes performed.'); return; } if (users.length === 0) { console.log('✓ Nothing to do.'); return; } let updated = 0; for (const u of users) { const data: { phoneHash?: string; emailHash?: string | null } = {}; if (u.phone) { const newPhoneHash = hash(u.phone); if (newPhoneHash !== u.phoneHash) data.phoneHash = newPhoneHash; } if (u.email) { const newEmailHash = hash(u.email); if (newEmailHash !== u.emailHash) data.emailHash = newEmailHash; } else if (u.emailHash) { // user has hash but no email — clear stale hash data.emailHash = null; } if (Object.keys(data).length === 0) continue; await prisma.user.update({ where: { id: u.id }, data }); updated += 1; } console.log(`✓ Updated ${updated} users.`); } main() .catch((err) => { console.error(err); process.exitCode = 1; }) .finally(async () => { await prisma.$disconnect(); await pool.end(); });