125 lines
4.2 KiB
JavaScript
125 lines
4.2 KiB
JavaScript
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) || '';
|
|
}
|