/** * Server-side PII encryption for EncryptID * * AES-256-GCM encryption and HMAC-SHA256 hashing derived from JWT_SECRET via HKDF. * Used to encrypt PII at rest in PostgreSQL and provide deterministic hashes for lookups. */ const JWT_SECRET = process.env.JWT_SECRET; if (!JWT_SECRET) throw new Error('JWT_SECRET environment variable is required'); const encoder = new TextEncoder(); const decoder = new TextDecoder(); // ── Cached keys ── let _piiKey: CryptoKey | null = null; let _hmacKey: CryptoKey | null = null; async function getPIIKey(): Promise { if (_piiKey) return _piiKey; const base = await crypto.subtle.importKey('raw', encoder.encode(JWT_SECRET), { name: 'HKDF' }, false, ['deriveKey']); _piiKey = await crypto.subtle.deriveKey( { name: 'HKDF', hash: 'SHA-256', salt: encoder.encode('pii-v1'), info: new Uint8Array(0) }, base, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'], ); return _piiKey; } async function getHMACKey(): Promise { if (_hmacKey) return _hmacKey; const base = await crypto.subtle.importKey('raw', encoder.encode(JWT_SECRET), { name: 'HKDF' }, false, ['deriveKey']); _hmacKey = await crypto.subtle.deriveKey( { name: 'HKDF', hash: 'SHA-256', salt: encoder.encode('pii-hash-v1'), info: new Uint8Array(0) }, base, { name: 'HMAC', hash: 'SHA-256', length: 256 }, false, ['sign'], ); return _hmacKey; } // ── Public API ── /** * Encrypt a PII field for storage. Returns "iv.ciphertext" (base64url), or null if input is null/undefined. */ export async function encryptField(plaintext: string | null | undefined): Promise { if (plaintext == null || plaintext === '') return null; const key = await getPIIKey(); const iv = crypto.getRandomValues(new Uint8Array(12)); const ct = new Uint8Array(await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, key, encoder.encode(plaintext), )); return `${Buffer.from(iv).toString('base64url')}.${Buffer.from(ct).toString('base64url')}`; } /** * Decrypt a stored PII field. Returns plaintext, or null if input is null. * Falls back to returning the value as-is if it doesn't contain a "." separator (legacy plaintext). */ export async function decryptField(stored: string | null | undefined): Promise { if (stored == null || stored === '') return null; const dotIdx = stored.indexOf('.'); if (dotIdx === -1) return stored; // legacy plaintext passthrough const key = await getPIIKey(); const iv = Buffer.from(stored.slice(0, dotIdx), 'base64url'); const ct = Buffer.from(stored.slice(dotIdx + 1), 'base64url'); const plain = await crypto.subtle.decrypt( { name: 'AES-GCM', iv }, key, ct, ); return decoder.decode(plain); } /** * Compute a deterministic HMAC-SHA256 hash for equality lookups. * Input is lowercased and trimmed before hashing. */ export async function hashForLookup(value: string): Promise { const key = await getHMACKey(); const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(value.toLowerCase().trim())); return Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join(''); }