rspace-online/src/encryptid/server-crypto.ts

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