From 9605262fe9e6ac5259be650fa9523d23e9f957fb Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 22 Apr 2026 01:00:25 +0200 Subject: [PATCH] feat(security): AES-256-GCM at-rest encryption for encounters and memories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the existing crypto helper (already used for audio backups and the Nextcloud token) to cover every column that can hold PHI: - saved_encounters.transcript, .generated_note, .partial_data - user_memories.content (templates + Dragon-style corrections) - user_memories.name (auto-derived from original snippet on corrections, so effectively PHI) Reads decrypt transparently. Legacy plaintext rows continue to work — decryptString passes non-enc1: values through unchanged — so no migration is required; rows re-encrypt on their next save. The encounters list query previously used LEFT(transcript, 200) for a preview. With ciphertext that slice is meaningless, so the route now fetches the full columns, decrypts in Node, then slices. At 7-day auto- delete the row count is bounded and the cost is a handful of GCM decrypts per list call. user_memories ORDER BY moved from (category, name) to (category, id) since SQL can no longer order on encrypted names. Closes the HHS breach-notification safe-harbor gap on at-rest PHI. --- src/routes/encounters.js | 54 ++++++++++++++++++++++++++++++++-------- src/routes/memories.js | 29 +++++++++++++++++---- 2 files changed, 67 insertions(+), 16 deletions(-) diff --git a/src/routes/encounters.js b/src/routes/encounters.js index b3a523e..6ddc876 100644 --- a/src/routes/encounters.js +++ b/src/routes/encounters.js @@ -7,17 +7,46 @@ var router = express.Router(); var db = require('../db/database'); var { authMiddleware } = require('../middleware/auth'); var logger = require('../utils/logger'); +var cryptoUtil = require('../utils/crypto'); router.use(authMiddleware); +// Normalise partial_data to a string before encrypting so JSON objects and +// raw strings round-trip identically. Decryption returns the original string. +function partialToString(partial_data) { + if (partial_data == null) return '{}'; + if (typeof partial_data === 'object') return JSON.stringify(partial_data); + return partial_data; +} + // ── GET all saved encounters for current user ──────────────────────────── router.get('/encounters/saved', async function(req, res) { try { + // transcript/generated_note are encrypted at rest, so we can't slice on + // the server with LEFT(..,200). Fetch full columns, decrypt in Node, then + // preview. With a 7-day auto-delete the row count is small; the cost is + // a handful of AES-GCM decrypts per list call. var rows = await db.all( - "SELECT id, label, enc_type, status, created_at, updated_at, expires_at, LEFT(transcript, 200) as transcript_preview, LEFT(generated_note, 200) as note_preview FROM saved_encounters WHERE user_id = $1 AND expires_at > NOW() ORDER BY updated_at DESC", + "SELECT id, label, enc_type, status, created_at, updated_at, expires_at, transcript, generated_note FROM saved_encounters WHERE user_id = $1 AND expires_at > NOW() ORDER BY updated_at DESC", [req.user.id] ); - res.json({ success: true, encounters: rows }); + var out = rows.map(function (r) { + var t = '', n = ''; + try { t = cryptoUtil.decryptString(r.transcript) || ''; } catch (e) {} + try { n = cryptoUtil.decryptString(r.generated_note) || ''; } catch (e) {} + return { + id: r.id, + label: r.label, + enc_type: r.enc_type, + status: r.status, + created_at: r.created_at, + updated_at: r.updated_at, + expires_at: r.expires_at, + transcript_preview: t.substring(0, 200), + note_preview: n.substring(0, 200) + }; + }); + res.json({ success: true, encounters: out }); } catch (e) { logger.error('GET /encounters/saved', e.message); res.status(500).json({ error: 'Request failed' }); } }); @@ -29,6 +58,9 @@ router.get('/encounters/saved/:id', async function(req, res) { [req.params.id, req.user.id] ); if (!row) return res.status(404).json({ error: 'Encounter not found or expired' }); + try { row.transcript = cryptoUtil.decryptString(row.transcript); } catch (e) {} + try { row.generated_note = cryptoUtil.decryptString(row.generated_note); } catch (e) {} + try { row.partial_data = cryptoUtil.decryptString(row.partial_data); } catch (e) {} logger.audit(req.user.id, 'encounter_load', 'Loaded encounter: ' + (row.label || 'unlabeled') + ' (id:' + req.params.id + ')', req, { category: 'clinical' }); res.json({ success: true, encounter: row }); } catch (e) { logger.error('GET /encounters/saved/:id', e.message); res.status(500).json({ error: 'Request failed' }); } @@ -57,9 +89,9 @@ router.post('/encounters/saved', async function(req, res) { 'UPDATE saved_encounters SET label=$1, transcript=$2, generated_note=$3, partial_data=$4, status=$5, version=$6, updated_at=NOW() WHERE id=$7 AND user_id=$8 AND (version = $9 OR $9::int IS NULL)', [ label || 'Untitled', - transcript || '', - generated_note || '', - typeof partial_data === 'object' ? JSON.stringify(partial_data) : (partial_data || '{}'), + cryptoUtil.encryptString(transcript || ''), + cryptoUtil.encryptString(generated_note || ''), + cryptoUtil.encryptString(partialToString(partial_data)), status || 'active', newVersion, id, req.user.id, @@ -95,9 +127,9 @@ router.post('/encounters/saved', async function(req, res) { 'UPDATE saved_encounters SET label=$1, transcript=$2, generated_note=$3, partial_data=$4, status=$5, updated_at=NOW() WHERE id=$6 AND user_id=$7', [ label || 'Untitled', - transcript || '', - generated_note || '', - typeof partial_data === 'object' ? JSON.stringify(partial_data) : (partial_data || '{}'), + cryptoUtil.encryptString(transcript || ''), + cryptoUtil.encryptString(generated_note || ''), + cryptoUtil.encryptString(partialToString(partial_data)), status || 'active', dup.id, req.user.id ] @@ -113,9 +145,9 @@ router.post('/encounters/saved', async function(req, res) { req.user.id, label || 'Untitled', enc_type || 'encounter', - transcript || '', - generated_note || '', - typeof partial_data === 'object' ? JSON.stringify(partial_data) : (partial_data || '{}'), + cryptoUtil.encryptString(transcript || ''), + cryptoUtil.encryptString(generated_note || ''), + cryptoUtil.encryptString(partialToString(partial_data)), status || 'active', idempotency_key || null, autoDeleteDays diff --git a/src/routes/memories.js b/src/routes/memories.js index adc98ae..ed5c321 100644 --- a/src/routes/memories.js +++ b/src/routes/memories.js @@ -7,9 +7,19 @@ var router = express.Router(); var db = require('../db/database'); var { authMiddleware } = require('../middleware/auth'); var logger = require('../utils/logger'); +var cryptoUtil = require('../utils/crypto'); router.use(authMiddleware); +// Decrypt a row's user-facing fields. Safe against legacy plaintext rows — +// cryptoUtil.decryptString passes through values without the "enc1:" prefix. +function decryptMemory(row) { + if (!row) return row; + try { row.name = cryptoUtil.decryptString(row.name); } catch (e) {} + try { row.content = cryptoUtil.decryptString(row.content); } catch (e) {} + return row; +} + var VALID_CATEGORIES = [ 'physical_exam', 'ros', 'encounter_format', 'family_history', 'assessment_plan', 'custom', 'template_soap', 'template_hpi', 'template_wellvisit', 'template_sickvisit', @@ -19,10 +29,13 @@ var VALID_CATEGORIES = [ // ── GET all memories for current user ─────────────────────────────────── router.get('/memories', async function(req, res) { try { + // Cannot ORDER BY encrypted `name`; order by id as a stable proxy after + // per-category sort. Frontend can re-sort alphabetically client-side. var rows = await db.all( - 'SELECT id, category, name, content, created_at, updated_at FROM user_memories WHERE user_id = $1 ORDER BY category, name', + 'SELECT id, category, name, content, created_at, updated_at FROM user_memories WHERE user_id = $1 ORDER BY category, id', [req.user.id] ); + rows.forEach(decryptMemory); res.json({ success: true, memories: rows }); } catch (e) { logger.error('GET /memories', e.message); res.status(500).json({ error: 'Request failed' }); } }); @@ -41,7 +54,12 @@ router.post('/memories', async function(req, res) { var result = await db.run( 'INSERT INTO user_memories (user_id, category, name, content) VALUES ($1,$2,$3,$4)', - [req.user.id, cat, name.trim().substring(0, 100), content.trim().substring(0, 5000)] + [ + req.user.id, + cat, + cryptoUtil.encryptString(name.trim().substring(0, 100)), + cryptoUtil.encryptString(content.trim().substring(0, 5000)) + ] ); res.json({ success: true, id: result.lastInsertRowid }); } catch (e) { logger.error('POST /memories', e.message); res.status(500).json({ error: 'Request failed' }); } @@ -55,9 +73,9 @@ router.put('/memories/:id', async function(req, res) { await db.run( 'UPDATE user_memories SET name=$1, category=$2, content=$3, updated_at=NOW() WHERE id=$4 AND user_id=$5', [ - (name || '').trim().substring(0, 100), + cryptoUtil.encryptString((name || '').trim().substring(0, 100)), cat, - (content || '').trim().substring(0, 5000), + cryptoUtil.encryptString((content || '').trim().substring(0, 5000)), req.params.id, req.user.id ] @@ -82,6 +100,7 @@ router.get('/memories/context', async function(req, res) { [req.user.id] ); if (rows.length === 0) return res.json({ success: true, context: '' }); + rows.forEach(decryptMemory); var templates = []; var corrections = []; @@ -153,7 +172,7 @@ router.post('/memories/correction', async function(req, res) { await db.run( 'INSERT INTO user_memories (user_id, category, name, content) VALUES ($1,$2,$3,$4)', - [req.user.id, cat, name, content] + [req.user.id, cat, cryptoUtil.encryptString(name), cryptoUtil.encryptString(content)] ); res.json({ success: true }); } catch (e) { logger.error('POST /memories/correction', e.message); res.status(500).json({ error: 'Request failed' }); }