86 lines
3.3 KiB
JavaScript
86 lines
3.3 KiB
JavaScript
'use strict';
|
|
|
|
const crypto = require('crypto');
|
|
|
|
const ALGORITHM = 'aes-256-gcm';
|
|
const IV_BYTES = 12;
|
|
const TAG_BYTES = 16;
|
|
// Domain-separation label for HKDF. Changing this invalidates all stored v2 secrets.
|
|
const HKDF_INFO = 'bill-tracker-token-encryption-v1';
|
|
// Prefix that identifies ciphertext produced with HKDF key derivation.
|
|
const V2_PREFIX = 'v2:';
|
|
|
|
let _warnedAutoKey = false;
|
|
|
|
// Returns the raw key material (IKM) without derivation — shared by both paths.
|
|
function getIkm() {
|
|
const envRaw = process.env.TOKEN_ENCRYPTION_KEY || '';
|
|
if (envRaw) {
|
|
const buf = Buffer.from(envRaw, 'utf8');
|
|
if (buf.length < 32) throw new Error('TOKEN_ENCRYPTION_KEY must be at least 32 bytes');
|
|
return buf;
|
|
}
|
|
|
|
if (!_warnedAutoKey) {
|
|
_warnedAutoKey = true;
|
|
console.warn(
|
|
'[security] TOKEN_ENCRYPTION_KEY is not set. Using an auto-generated key ' +
|
|
'stored in the database alongside the encrypted data. Set TOKEN_ENCRYPTION_KEY ' +
|
|
'as an environment variable to keep the key separate from the data it protects.',
|
|
);
|
|
}
|
|
|
|
// Lazy-require to avoid circular dependency at module load time
|
|
const { getSetting, setSetting } = require('../db/database');
|
|
let stored = getSetting('_auto_encryption_key');
|
|
if (!stored) {
|
|
stored = crypto.randomBytes(48).toString('hex');
|
|
setSetting('_auto_encryption_key', stored);
|
|
}
|
|
return Buffer.from(stored, 'utf8');
|
|
}
|
|
|
|
// Current derivation: HKDF-SHA-256 (RFC 5869). Used for all new encryptions.
|
|
function deriveKey(ikm) {
|
|
return Buffer.from(crypto.hkdfSync('sha256', ikm, /* salt */ '', HKDF_INFO, 32));
|
|
}
|
|
|
|
// Legacy derivation: raw SHA-256. Used only to decrypt pre-v0.78 ciphertext.
|
|
function deriveLegacyKey(ikm) {
|
|
return crypto.createHash('sha256').update(ikm).digest();
|
|
}
|
|
|
|
function getKey() { return deriveKey(getIkm()); }
|
|
function getLegacyKey() { return deriveLegacyKey(getIkm()); }
|
|
|
|
// No-op now that the key is always available — kept for call-site compatibility
|
|
function assertEncryptionReady() {}
|
|
|
|
// Always produces v2-format ciphertext (HKDF key derivation).
|
|
function encryptSecret(plaintext) {
|
|
const key = getKey();
|
|
const iv = crypto.randomBytes(IV_BYTES);
|
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: TAG_BYTES });
|
|
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
const tag = cipher.getAuthTag();
|
|
return `${V2_PREFIX}${iv.toString('hex')}:${tag.toString('hex')}:${ct.toString('hex')}`;
|
|
}
|
|
|
|
// Decrypts both v2 (HKDF) and legacy (SHA-256) ciphertext transparently.
|
|
function decryptSecret(stored) {
|
|
const isV2 = stored.startsWith(V2_PREFIX);
|
|
const payload = isV2 ? stored.slice(V2_PREFIX.length) : stored;
|
|
const parts = payload.split(':');
|
|
if (parts.length !== 3) throw new Error('Invalid encrypted secret format');
|
|
const [ivHex, tagHex, ctHex] = parts;
|
|
const key = isV2 ? getKey() : getLegacyKey();
|
|
const iv = Buffer.from(ivHex, 'hex');
|
|
const tag = Buffer.from(tagHex, 'hex');
|
|
const ct = Buffer.from(ctHex, 'hex');
|
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_BYTES });
|
|
decipher.setAuthTag(tag);
|
|
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
|
|
}
|
|
|
|
module.exports = { assertEncryptionReady, encryptSecret, decryptSecret };
|