Add AES-256-GCM encryption for sensitive personal data fields
- New backend/src/lib/crypto.ts: encrypt/decrypt (AES-256-GCM) + passportToken (HMAC-SHA256) - Migration 012: birth_date/passport_issue_date → TEXT, add passport_search_token indexes - guests.ts: encrypt passport_series/number/birth_date on write, decrypt on read, passport search via HMAC token (?passport= param) instead of plaintext LIKE - booking-guests.ts: encrypt all sensitive fields, search token for passport lookup - api.ts: guests.list() supports passport= param for exact passport lookup - BookingDetailPanel: use passport param for passport-based autocomplete Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
51
backend/src/lib/crypto.ts
Normal file
51
backend/src/lib/crypto.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { createCipheriv, createDecipheriv, createHmac, randomBytes } from 'crypto'
|
||||
|
||||
const KEY = Buffer.from(process.env.ENCRYPTION_KEY ?? '', 'hex') // 32 bytes
|
||||
const HMAC = process.env.HMAC_KEY ?? ''
|
||||
|
||||
if (process.env.NODE_ENV !== 'test' && (KEY.length !== 32 || !HMAC)) {
|
||||
throw new Error('ENCRYPTION_KEY (64 hex chars) and HMAC_KEY must be set in environment')
|
||||
}
|
||||
|
||||
// ── Encrypt ───────────────────────────────────────────────────────────────────
|
||||
// Format: hex(iv 12b) : hex(authTag 16b) : hex(ciphertext)
|
||||
export function encrypt(plaintext: string): string {
|
||||
const iv = randomBytes(12)
|
||||
const cipher = createCipheriv('aes-256-gcm', KEY, iv)
|
||||
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
||||
const tag = cipher.getAuthTag()
|
||||
return `${iv.toString('hex')}:${tag.toString('hex')}:${enc.toString('hex')}`
|
||||
}
|
||||
|
||||
// ── Decrypt ───────────────────────────────────────────────────────────────────
|
||||
export function decrypt(data: string): string {
|
||||
const parts = data.split(':')
|
||||
if (parts.length !== 3) return data // не зашифровано — вернуть как есть
|
||||
const [ivHex, tagHex, encHex] = parts
|
||||
if (ivHex.length !== 24) return data // не наш формат
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const authTag = Buffer.from(tagHex, 'hex')
|
||||
const enc = Buffer.from(encHex, 'hex')
|
||||
const decipher = createDecipheriv('aes-256-gcm', KEY, iv)
|
||||
decipher.setAuthTag(authTag)
|
||||
return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8')
|
||||
}
|
||||
|
||||
// ── Null-safe helpers ─────────────────────────────────────────────────────────
|
||||
export function encryptField(val: string | null | undefined): string | null {
|
||||
if (!val) return null
|
||||
return encrypt(val)
|
||||
}
|
||||
|
||||
export function decryptField(val: string | null | undefined): string | null {
|
||||
if (!val) return null
|
||||
try { return decrypt(val) } catch { return val } // старые незашифрованные данные
|
||||
}
|
||||
|
||||
// ── Passport search token — HMAC(series+number) ───────────────────────────────
|
||||
// Хранится рядом с зашифрованными полями для точного поиска без расшифровки
|
||||
export function passportToken(seriesAndNumber: string): string {
|
||||
return createHmac('sha256', HMAC)
|
||||
.update(seriesAndNumber.toLowerCase().replace(/\s/g, ''))
|
||||
.digest('hex')
|
||||
}
|
||||
Reference in New Issue
Block a user