90 lines
3.2 KiB
TypeScript
90 lines
3.2 KiB
TypeScript
/**
|
|
* 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<CryptoKey> {
|
|
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<CryptoKey> {
|
|
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<string | null> {
|
|
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<string | null> {
|
|
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<string> {
|
|
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('');
|
|
}
|