| import crypto from 'crypto'; |
| import fs from 'fs/promises'; |
| import path from 'path'; |
|
|
| const JSON_FORMAT_VERSION = 2; |
| const BINARY_FORMAT_VERSION = 1; |
| const ALGORITHM = 'aes-256-gcm'; |
| const KEY_LENGTH = 32; |
| const IV_LENGTH = 12; |
| const AUTH_TAG_LENGTH = 16; |
|
|
| function getKey() { |
| const keyEnv = process.env.DATA_ENCRYPTION_KEY; |
| if (!keyEnv) throw new Error('DATA_ENCRYPTION_KEY environment variable not set'); |
| return crypto.createHash('sha256').update(keyEnv).digest().subarray(0, KEY_LENGTH); |
| } |
|
|
| function normalizeAad(aad = '') { |
| if (Buffer.isBuffer(aad)) return aad; |
| return Buffer.from(String(aad || ''), 'utf8'); |
| } |
|
|
| export function encryptBuffer(buffer, aad = '') { |
| const key = getKey(); |
| const iv = crypto.randomBytes(IV_LENGTH); |
| const cipher = crypto.createCipheriv(ALGORITHM, key, iv); |
| const aadBuffer = normalizeAad(aad); |
| if (aadBuffer.length) cipher.setAAD(aadBuffer); |
| const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]); |
| const authTag = cipher.getAuthTag(); |
| return { |
| version: BINARY_FORMAT_VERSION, |
| iv, |
| authTag, |
| encrypted, |
| }; |
| } |
|
|
| export function decryptBuffer(payload, aad = '') { |
| const key = getKey(); |
| const iv = Buffer.isBuffer(payload?.iv) ? payload.iv : Buffer.from(payload?.iv || '', 'hex'); |
| const authTag = Buffer.isBuffer(payload?.authTag) |
| ? payload.authTag |
| : Buffer.from(payload?.authTag || '', 'hex'); |
| const encrypted = Buffer.isBuffer(payload?.encrypted) |
| ? payload.encrypted |
| : Buffer.from(payload?.encrypted || '', 'hex'); |
|
|
| const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); |
| const aadBuffer = normalizeAad(aad); |
| if (aadBuffer.length) decipher.setAAD(aadBuffer); |
| decipher.setAuthTag(authTag); |
| return Buffer.concat([decipher.update(encrypted), decipher.final()]); |
| } |
|
|
| export function packEncryptedBuffer(payload) { |
| const header = Buffer.allocUnsafe(1 + 1 + payload.iv.length + payload.authTag.length); |
| header.writeUInt8(payload.version || BINARY_FORMAT_VERSION, 0); |
| header.writeUInt8(payload.iv.length, 1); |
| payload.iv.copy(header, 2); |
| payload.authTag.copy(header, 2 + payload.iv.length); |
| return Buffer.concat([header, payload.encrypted]); |
| } |
|
|
| export function unpackEncryptedBuffer(buffer) { |
| const version = buffer.readUInt8(0); |
| if (version !== BINARY_FORMAT_VERSION) { |
| throw new Error(`Unsupported encrypted buffer version: ${version}`); |
| } |
| const ivLength = buffer.readUInt8(1); |
| const ivStart = 2; |
| const ivEnd = ivStart + ivLength; |
| const tagEnd = ivEnd + AUTH_TAG_LENGTH; |
| return { |
| version, |
| iv: buffer.subarray(ivStart, ivEnd), |
| authTag: buffer.subarray(ivEnd, tagEnd), |
| encrypted: buffer.subarray(tagEnd), |
| }; |
| } |
|
|
| export async function writeEncryptedFile(filePath, buffer, aad = '') { |
| await fs.mkdir(path.dirname(filePath), { recursive: true }); |
| const payload = encryptBuffer(buffer, aad); |
| await fs.writeFile(filePath, packEncryptedBuffer(payload)); |
| } |
|
|
| export async function readEncryptedFile(filePath, aad = '') { |
| const packed = await fs.readFile(filePath); |
| return decryptBuffer(unpackEncryptedBuffer(packed), aad); |
| } |
|
|
| function legacyDecryptJson(encryptedData) { |
| const key = getKey(); |
| const decipher = crypto.createDecipher(ALGORITHM, key); |
| decipher.setAuthTag(Buffer.from(encryptedData.authTag, 'hex')); |
| let decrypted = decipher.update(encryptedData.encrypted, 'hex', 'utf8'); |
| decrypted += decipher.final('utf8'); |
| return JSON.parse(decrypted); |
| } |
|
|
| export function encryptJson(data, aad = '') { |
| const payload = encryptBuffer(Buffer.from(JSON.stringify(data), 'utf8'), aad); |
| return { |
| version: JSON_FORMAT_VERSION, |
| iv: payload.iv.toString('hex'), |
| authTag: payload.authTag.toString('hex'), |
| encrypted: payload.encrypted.toString('hex'), |
| }; |
| } |
|
|
| export function decryptJson(encryptedData, aad = '') { |
| if (!encryptedData) return null; |
| if ((encryptedData.version || 0) >= JSON_FORMAT_VERSION) { |
| const decrypted = decryptBuffer(encryptedData, aad); |
| return JSON.parse(decrypted.toString('utf8')); |
| } |
| return legacyDecryptJson(encryptedData); |
| } |
|
|
| export async function saveEncryptedJson(filePath, data, aad = '') { |
| const encrypted = encryptJson(data, aad); |
| await fs.mkdir(path.dirname(filePath), { recursive: true }); |
| await fs.writeFile(filePath, JSON.stringify(encrypted, null, 2), 'utf8'); |
| } |
|
|
| export async function loadEncryptedJson(filePath, aad = '') { |
| try { |
| const content = await fs.readFile(filePath, 'utf8'); |
| const encrypted = JSON.parse(content); |
| return decryptJson(encrypted, aad); |
| } catch (err) { |
| if (err.code === 'ENOENT') return null; |
| throw err; |
| } |
| } |
|
|