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:
parent
b5dbc98c75
commit
9605262fe9
2 changed files with 67 additions and 16 deletions
|
|
@ -7,17 +7,46 @@ var router = express.Router();
|
||||||
var db = require('../db/database');
|
var db = require('../db/database');
|
||||||
var { authMiddleware } = require('../middleware/auth');
|
var { authMiddleware } = require('../middleware/auth');
|
||||||
var logger = require('../utils/logger');
|
var logger = require('../utils/logger');
|
||||||
|
var cryptoUtil = require('../utils/crypto');
|
||||||
|
|
||||||
router.use(authMiddleware);
|
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 ────────────────────────────
|
// ── GET all saved encounters for current user ────────────────────────────
|
||||||
router.get('/encounters/saved', async function(req, res) {
|
router.get('/encounters/saved', async function(req, res) {
|
||||||
try {
|
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(
|
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]
|
[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' }); }
|
} 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]
|
[req.params.id, req.user.id]
|
||||||
);
|
);
|
||||||
if (!row) return res.status(404).json({ error: 'Encounter not found or expired' });
|
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' });
|
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 });
|
res.json({ success: true, encounter: row });
|
||||||
} catch (e) { logger.error('GET /encounters/saved/:id', e.message); res.status(500).json({ error: 'Request failed' }); }
|
} 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)',
|
'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',
|
label || 'Untitled',
|
||||||
transcript || '',
|
cryptoUtil.encryptString(transcript || ''),
|
||||||
generated_note || '',
|
cryptoUtil.encryptString(generated_note || ''),
|
||||||
typeof partial_data === 'object' ? JSON.stringify(partial_data) : (partial_data || '{}'),
|
cryptoUtil.encryptString(partialToString(partial_data)),
|
||||||
status || 'active',
|
status || 'active',
|
||||||
newVersion,
|
newVersion,
|
||||||
id, req.user.id,
|
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',
|
'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',
|
label || 'Untitled',
|
||||||
transcript || '',
|
cryptoUtil.encryptString(transcript || ''),
|
||||||
generated_note || '',
|
cryptoUtil.encryptString(generated_note || ''),
|
||||||
typeof partial_data === 'object' ? JSON.stringify(partial_data) : (partial_data || '{}'),
|
cryptoUtil.encryptString(partialToString(partial_data)),
|
||||||
status || 'active',
|
status || 'active',
|
||||||
dup.id, req.user.id
|
dup.id, req.user.id
|
||||||
]
|
]
|
||||||
|
|
@ -113,9 +145,9 @@ router.post('/encounters/saved', async function(req, res) {
|
||||||
req.user.id,
|
req.user.id,
|
||||||
label || 'Untitled',
|
label || 'Untitled',
|
||||||
enc_type || 'encounter',
|
enc_type || 'encounter',
|
||||||
transcript || '',
|
cryptoUtil.encryptString(transcript || ''),
|
||||||
generated_note || '',
|
cryptoUtil.encryptString(generated_note || ''),
|
||||||
typeof partial_data === 'object' ? JSON.stringify(partial_data) : (partial_data || '{}'),
|
cryptoUtil.encryptString(partialToString(partial_data)),
|
||||||
status || 'active',
|
status || 'active',
|
||||||
idempotency_key || null,
|
idempotency_key || null,
|
||||||
autoDeleteDays
|
autoDeleteDays
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,19 @@ var router = express.Router();
|
||||||
var db = require('../db/database');
|
var db = require('../db/database');
|
||||||
var { authMiddleware } = require('../middleware/auth');
|
var { authMiddleware } = require('../middleware/auth');
|
||||||
var logger = require('../utils/logger');
|
var logger = require('../utils/logger');
|
||||||
|
var cryptoUtil = require('../utils/crypto');
|
||||||
|
|
||||||
router.use(authMiddleware);
|
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 = [
|
var VALID_CATEGORIES = [
|
||||||
'physical_exam', 'ros', 'encounter_format', 'family_history', 'assessment_plan', 'custom',
|
'physical_exam', 'ros', 'encounter_format', 'family_history', 'assessment_plan', 'custom',
|
||||||
'template_soap', 'template_hpi', 'template_wellvisit', 'template_sickvisit',
|
'template_soap', 'template_hpi', 'template_wellvisit', 'template_sickvisit',
|
||||||
|
|
@ -19,10 +29,13 @@ var VALID_CATEGORIES = [
|
||||||
// ── GET all memories for current user ───────────────────────────────────
|
// ── GET all memories for current user ───────────────────────────────────
|
||||||
router.get('/memories', async function(req, res) {
|
router.get('/memories', async function(req, res) {
|
||||||
try {
|
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(
|
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]
|
[req.user.id]
|
||||||
);
|
);
|
||||||
|
rows.forEach(decryptMemory);
|
||||||
res.json({ success: true, memories: rows });
|
res.json({ success: true, memories: rows });
|
||||||
} catch (e) { logger.error('GET /memories', e.message); res.status(500).json({ error: 'Request failed' }); }
|
} 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(
|
var result = await db.run(
|
||||||
'INSERT INTO user_memories (user_id, category, name, content) VALUES ($1,$2,$3,$4)',
|
'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 });
|
res.json({ success: true, id: result.lastInsertRowid });
|
||||||
} catch (e) { logger.error('POST /memories', e.message); res.status(500).json({ error: 'Request failed' }); }
|
} 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(
|
await db.run(
|
||||||
'UPDATE user_memories SET name=$1, category=$2, content=$3, updated_at=NOW() WHERE id=$4 AND user_id=$5',
|
'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,
|
cat,
|
||||||
(content || '').trim().substring(0, 5000),
|
cryptoUtil.encryptString((content || '').trim().substring(0, 5000)),
|
||||||
req.params.id,
|
req.params.id,
|
||||||
req.user.id
|
req.user.id
|
||||||
]
|
]
|
||||||
|
|
@ -82,6 +100,7 @@ router.get('/memories/context', async function(req, res) {
|
||||||
[req.user.id]
|
[req.user.id]
|
||||||
);
|
);
|
||||||
if (rows.length === 0) return res.json({ success: true, context: '' });
|
if (rows.length === 0) return res.json({ success: true, context: '' });
|
||||||
|
rows.forEach(decryptMemory);
|
||||||
|
|
||||||
var templates = [];
|
var templates = [];
|
||||||
var corrections = [];
|
var corrections = [];
|
||||||
|
|
@ -153,7 +172,7 @@ router.post('/memories/correction', async function(req, res) {
|
||||||
|
|
||||||
await db.run(
|
await db.run(
|
||||||
'INSERT INTO user_memories (user_id, category, name, content) VALUES ($1,$2,$3,$4)',
|
'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 });
|
res.json({ success: true });
|
||||||
} catch (e) { logger.error('POST /memories/correction', e.message); res.status(500).json({ error: 'Request failed' }); }
|
} catch (e) { logger.error('POST /memories/correction', e.message); res.status(500).json({ error: 'Request failed' }); }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue