pediatric-ai-scribe-v3/src/utils/crypto.js
Daniel 7c45367c02 Security hardening: PHI encryption, argon2, DOMPurify, SRI
- 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.
2026-04-14 02:49:38 +02:00

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; }
};