feat(security): AES-256-GCM at-rest encryption for encounters and memories

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.
This commit is contained in:
Daniel 2026-04-22 01:00:25 +02:00
parent b5dbc98c75
commit 9605262fe9
2 changed files with 67 additions and 16 deletions

View file

@ -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

View file

@ -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' }); }