import crypto from 'node:crypto'; import { env } from '../config/env.js'; function keyMaterial() { const raw = env.PII_ENCRYPTION_KEY; if (!raw) { if (env.NODE_ENV !== 'development') { throw new Error('Set PII_ENCRYPTION_KEY (32-byte base64) outside development.'); } return crypto.createHash('sha256').update(`dev-pii|${env.JWT_SECRET}`).digest(); } const fromB64 = Buffer.from(raw, 'base64'); if (fromB64.length === 32) return fromB64; return crypto.createHash('sha256').update(raw).digest(); } function pepper() { if (env.PII_PEPPER) return env.PII_PEPPER; if (env.NODE_ENV !== 'development') throw new Error('Set PII_PEPPER outside development.'); return `dev-pepper|${env.JWT_SECRET}`; } export function isCipherPayload(value) { return Boolean(value && typeof value === 'object' && value.v && value.iv && value.tag && value.ct); } export function encryptPii(plain) { if (plain == null || plain === '') return plain; const text = String(plain); const iv = crypto.randomBytes(12); const key = keyMaterial(); const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); const ct = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]); const tag = cipher.getAuthTag(); return { v: 1, kid: env.PII_KEY_ID || 'k1', iv: iv.toString('base64'), tag: tag.toString('base64'), ct: ct.toString('base64'), }; } export function decryptPii(value) { if (value == null || value === '') return value == null ? value : ''; if (typeof value === 'number') return value; if (typeof value === 'string') return value; if (!isCipherPayload(value)) return typeof value === 'object' ? '' : value; try { const key = keyMaterial(); const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(value.iv, 'base64')); decipher.setAuthTag(Buffer.from(value.tag, 'base64')); const plain = Buffer.concat([decipher.update(Buffer.from(value.ct, 'base64')), decipher.final()]); return plain.toString('utf8'); } catch (err) { console.error('[pii] decrypt failed', err.message); return ''; } } export function hashMobile(mobile) { const digits = String(mobile || '').replace(/\D/g, ''); if (!digits) return ''; return crypto.createHmac('sha256', pepper()).update(digits).digest('hex'); } export function hashName(name) { const normalized = String(name || '').trim().toLowerCase(); if (!normalized) return ''; return crypto.createHmac('sha256', pepper()).update(normalized).digest('hex'); } export function isMobileNumber(mobile) { return /^\d{10}$/.test(String(mobile || '').trim()); } export function applyOnboardedPii(target, fields) { if (fields.name != null) { target.name = encryptPii(fields.name); target.nameHash = hashName(fields.name); } if (fields.mobile != null) { target.mobile = encryptPii(fields.mobile); target.mobileHash = hashMobile(fields.mobile); } if (fields.age !== undefined) { target.age = fields.age == null || fields.age === '' ? null : encryptPii(String(fields.age)); } if (fields.gender !== undefined) target.gender = fields.gender ? encryptPii(fields.gender) : ''; if (fields.lungCondition !== undefined) { target.lungCondition = fields.lungCondition ? encryptPii(fields.lungCondition) : ''; } if (fields.otherHealthCondition !== undefined) { target.otherHealthCondition = fields.otherHealthCondition ? encryptPii(fields.otherHealthCondition) : ''; } return target; } export function readOnboardedPii(item) { if (!item || typeof item !== 'object') { return { name: '', mobile: '', age: null, gender: '', lungCondition: '', otherHealthCondition: '' }; } const agePlain = decryptPii(item.age); const age = agePlain == null || agePlain === '' ? null : Number(agePlain); return { name: decryptPii(item.name) || '', mobile: decryptPii(item.mobile) || '', age: Number.isNaN(age) ? null : age, gender: decryptPii(item.gender) || '', lungCondition: decryptPii(item.lungCondition) || '', otherHealthCondition: decryptPii(item.otherHealthCondition) || '', }; } export function encryptSecret(value) { if (!value) return ''; if (isCipherPayload(value)) return value; return encryptPii(value); } export function decryptSecret(value) { if (!value) return ''; return decryptPii(value) || ''; }