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(); + });