From cec643ce5ff8b01b4aa3e8962fed78b55d552123 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 30 Apr 2026 14:26:26 +0700 Subject: [PATCH] fix(auth): backfill HMAC phone/email hashes so login works against the live DB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production DB had 11 User rows with `phoneHash` / `emailHash` either NULL (legacy seed before the privacy hashing layer) or filled with the wrong format (an earlier short-circuit used plain SHA-256). Either way, `PrismaUserRepository.findByPhone` calls `fieldEncryption.computeHash(phone)` and looks up `phoneHash` — returning null and surfacing "Số điện thoại hoặc mật khẩu không đúng" even when the password is correct. Two fixes: 1. `scripts/backfill-user-pii-hashes.ts` — re-run-safe one-shot: - Reads `FIELD_ENCRYPTION_KEY` (or `KYC_ENCRYPTION_KEY`), - Derives the same HMAC key the runtime uses (HKDF-SHA256 with the "goodgo-field-hash" info string), - Recomputes `phoneHash` + `emailHash` for every User and writes them back if they differ from the stored value. Verified: after run, login of seed-admin-001, seed-agent-001, seed-buyer-001 and seed-developer-001 all succeed against api.goodgo.vn with the seed default password. 2. `prisma/seed.ts` — `seedUsers()` now computes the HMAC hashes on create AND update (idempotent), so future `pnpm db:seed` runs produce rows that work with the runtime auth flow out of the box. When `FIELD_ENCRYPTION_KEY` isn't set (dev mode without encryption), the hash is `null` and the repository falls back to the plaintext `phone` / `email` query — preserving local-dev behaviour. Default seed password remains `Velik@2026`. Co-Authored-By: Claude Opus 4.7 (1M context) --- prisma/seed.ts | 45 +++++++++++- scripts/backfill-user-pii-hashes.ts | 108 ++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 scripts/backfill-user-pii-hashes.ts diff --git a/prisma/seed.ts b/prisma/seed.ts index 7f937d9..53df83e 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -8,6 +8,7 @@ * Phone: 0876677771 | Email: hongochai10@icloud.com | Password: Velik@2026 */ +import * as crypto from 'node:crypto'; import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaClient, @@ -64,6 +65,35 @@ const oneYearLater = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000); // Phase 2 — Users & Identity // ============================================================================= +/** + * Compute the same `phoneHash` / `emailHash` that the runtime + * `FieldEncryptionService` writes — HMAC-SHA256 over the lowercased, + * trimmed value, keyed by HKDF(FIELD_ENCRYPTION_KEY, "goodgo-field-hash"). + * If neither key is set, returns `null` (matches dev-mode behaviour + * where lookups fall back to plaintext `phone` / `email`). + */ +function buildFieldHasher(): (value: string | null | undefined) => string | null { + const primaryKey = + process.env['FIELD_ENCRYPTION_KEY'] ?? process.env['KYC_ENCRYPTION_KEY']; + if (!primaryKey) return () => null; + const hmacKey = crypto.hkdfSync( + 'sha256', + Buffer.from(primaryKey, 'hex'), + Buffer.alloc(0), + Buffer.from('goodgo-field-hash', 'utf8'), + 32, + ) as unknown as Buffer; + return (value) => { + if (!value) return null; + return crypto + .createHmac('sha256', hmacKey) + .update(value.toLowerCase().trim()) + .digest('hex'); + }; +} + +const computeFieldHash = buildFieldHasher(); + async function seedUsers(passwordHash: string) { console.log('🔐 Seeding users...'); @@ -79,11 +109,24 @@ async function seedUsers(passwordHash: string) { ]; for (const u of users) { + const phoneHash = computeFieldHash(u.phone); + const emailHash = computeFieldHash(u.email); await prisma.user.upsert({ where: { id: u.id }, - update: { passwordHash, email: u.email, fullName: u.fullName, role: u.role, kycStatus: u.kycStatus }, + update: { + passwordHash, + email: u.email, + fullName: u.fullName, + role: u.role, + kycStatus: u.kycStatus, + // Keep search-by-phone/email working when re-running seed against + // a freshly migrated DB or after a key rotation. + phoneHash, + emailHash, + }, create: { id: u.id, phone: u.phone, email: u.email, passwordHash, + phoneHash, emailHash, fullName: u.fullName, role: u.role, kycStatus: u.kycStatus, avatarUrl: u.avatarUrl, isActive: true, totpEnabled: false, totpBackupCodes: [], }, diff --git a/scripts/backfill-user-pii-hashes.ts b/scripts/backfill-user-pii-hashes.ts new file mode 100644 index 0000000..aafba2c --- /dev/null +++ b/scripts/backfill-user-pii-hashes.ts @@ -0,0 +1,108 @@ +/** + * 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(); + });