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 { 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
|
||||
|
|
|
|||
|
|
@ -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' }); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue