- App-layer AES-256-GCM crypto helper (src/utils/crypto.js) - Nextcloud tokens encrypted at rest; transparent migration on next use - Audio backups encrypted at rest (version byte 0x01 envelope); legacy rows still decrypt as-is until overwritten - argon2id password hashing via src/utils/passwords.js with bcrypt fallback; bcrypt hashes rehashed to argon2id on next successful login. argon2 package is optional — server keeps running with bcrypt only until npm install adds the native dep - PHI redactor for audit log details (src/utils/redact.js) — strips SSN, phone, email, DoB, long IDs; caps at 500 chars; detects note bodies - DOMPurify (cdnjs, SRI-pinned) replaces custom regex sanitizer in Learning Hub content rendering - SRI integrity hashes added for Font Awesome CSS and Chart.js - Magic-byte file-type verification on document uploads (src/utils/fileType.js) - Generic 500 error responses via src/utils/errors.js applied to nextcloud and audioBackups; full detail still logged server-side - DATA_ENCRYPTION_KEY env documented in .env.example Deploy: requires rebuild of the container image to pick up the new files and `npm install` (adds argon2). Existing users keep working because bcrypt stays available and crypto helpers pass through plaintext when the key is not yet set in dev.
91 lines
3.4 KiB
JavaScript
91 lines
3.4 KiB
JavaScript
// ============================================================
|
|
// AES-256-GCM helpers for application-layer encryption of PHI
|
|
// ============================================================
|
|
// Reads DATA_ENCRYPTION_KEY from env (hex, 32 bytes / 64 hex chars).
|
|
// Generate one with: openssl rand -hex 32
|
|
// ============================================================
|
|
var crypto = require('crypto');
|
|
|
|
var KEY = null;
|
|
var raw = process.env.DATA_ENCRYPTION_KEY;
|
|
if (raw) {
|
|
if (raw.length === 64 && /^[0-9a-fA-F]+$/.test(raw)) {
|
|
KEY = Buffer.from(raw, 'hex');
|
|
} else if (raw.length >= 32) {
|
|
// Fallback: derive a 32-byte key from any string via SHA-256.
|
|
KEY = crypto.createHash('sha256').update(raw).digest();
|
|
console.warn('[crypto] DATA_ENCRYPTION_KEY is not 64 hex chars; derived via SHA-256.');
|
|
}
|
|
}
|
|
|
|
if (!KEY && (process.env.NODE_ENV === 'production' || process.env.APP_URL)) {
|
|
console.error('[FATAL] DATA_ENCRYPTION_KEY is not set. Refusing to run in production.');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Ciphertext format: "enc1:" + base64( iv(12) || authTag(16) || ciphertext )
|
|
var PREFIX = 'enc1:';
|
|
|
|
function isEncrypted(value) {
|
|
return typeof value === 'string' && value.indexOf(PREFIX) === 0;
|
|
}
|
|
|
|
function encryptString(plaintext) {
|
|
if (plaintext == null) return plaintext;
|
|
if (!KEY) return plaintext; // dev mode: passthrough
|
|
var iv = crypto.randomBytes(12);
|
|
var cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv);
|
|
var ct = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
|
|
var tag = cipher.getAuthTag();
|
|
return PREFIX + Buffer.concat([iv, tag, ct]).toString('base64');
|
|
}
|
|
|
|
function decryptString(value) {
|
|
if (value == null) return value;
|
|
if (!isEncrypted(value)) return value; // plaintext (legacy row)
|
|
if (!KEY) throw new Error('Cannot decrypt: DATA_ENCRYPTION_KEY not set');
|
|
var buf = Buffer.from(String(value).slice(PREFIX.length), 'base64');
|
|
var iv = buf.subarray(0, 12);
|
|
var tag = buf.subarray(12, 28);
|
|
var ct = buf.subarray(28);
|
|
var decipher = crypto.createDecipheriv('aes-256-gcm', KEY, iv);
|
|
decipher.setAuthTag(tag);
|
|
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
|
|
}
|
|
|
|
function encryptBuffer(buf) {
|
|
if (!buf) return buf;
|
|
if (!KEY) return buf;
|
|
var iv = crypto.randomBytes(12);
|
|
var cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv);
|
|
var ct = Buffer.concat([cipher.update(buf), cipher.final()]);
|
|
var tag = cipher.getAuthTag();
|
|
// Version byte (0x01) + iv(12) + tag(16) + ciphertext
|
|
return Buffer.concat([Buffer.from([0x01]), iv, tag, ct]);
|
|
}
|
|
|
|
function isEncryptedBuffer(buf) {
|
|
return Buffer.isBuffer(buf) && buf.length > 29 && buf[0] === 0x01;
|
|
}
|
|
|
|
function decryptBuffer(buf) {
|
|
if (!Buffer.isBuffer(buf)) return buf;
|
|
if (!isEncryptedBuffer(buf)) return buf; // legacy (gzip without enc wrapper)
|
|
if (!KEY) throw new Error('Cannot decrypt buffer: DATA_ENCRYPTION_KEY not set');
|
|
var iv = buf.subarray(1, 13);
|
|
var tag = buf.subarray(13, 29);
|
|
var ct = buf.subarray(29);
|
|
var decipher = crypto.createDecipheriv('aes-256-gcm', KEY, iv);
|
|
decipher.setAuthTag(tag);
|
|
return Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
}
|
|
|
|
module.exports = {
|
|
encryptString: encryptString,
|
|
decryptString: decryptString,
|
|
isEncrypted: isEncrypted,
|
|
encryptBuffer: encryptBuffer,
|
|
decryptBuffer: decryptBuffer,
|
|
isEncryptedBuffer: isEncryptedBuffer,
|
|
hasKey: function() { return !!KEY; }
|
|
};
|