@erosolaraijs/cure 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/dist/bin/cure.d.ts +10 -0
- package/dist/bin/cure.d.ts.map +1 -0
- package/dist/bin/cure.js +169 -0
- package/dist/bin/cure.js.map +1 -0
- package/dist/capabilities/cancerTreatmentCapability.d.ts +167 -0
- package/dist/capabilities/cancerTreatmentCapability.d.ts.map +1 -0
- package/dist/capabilities/cancerTreatmentCapability.js +912 -0
- package/dist/capabilities/cancerTreatmentCapability.js.map +1 -0
- package/dist/capabilities/index.d.ts +2 -0
- package/dist/capabilities/index.d.ts.map +1 -0
- package/dist/capabilities/index.js +3 -0
- package/dist/capabilities/index.js.map +1 -0
- package/dist/compliance/hipaa.d.ts +337 -0
- package/dist/compliance/hipaa.d.ts.map +1 -0
- package/dist/compliance/hipaa.js +929 -0
- package/dist/compliance/hipaa.js.map +1 -0
- package/dist/examples/cancerTreatmentDemo.d.ts +21 -0
- package/dist/examples/cancerTreatmentDemo.d.ts.map +1 -0
- package/dist/examples/cancerTreatmentDemo.js +216 -0
- package/dist/examples/cancerTreatmentDemo.js.map +1 -0
- package/dist/integrations/clinicalTrials/clinicalTrialsGov.d.ts +265 -0
- package/dist/integrations/clinicalTrials/clinicalTrialsGov.d.ts.map +1 -0
- package/dist/integrations/clinicalTrials/clinicalTrialsGov.js +808 -0
- package/dist/integrations/clinicalTrials/clinicalTrialsGov.js.map +1 -0
- package/dist/integrations/ehr/fhir.d.ts +455 -0
- package/dist/integrations/ehr/fhir.d.ts.map +1 -0
- package/dist/integrations/ehr/fhir.js +859 -0
- package/dist/integrations/ehr/fhir.js.map +1 -0
- package/dist/integrations/genomics/genomicPlatforms.d.ts +362 -0
- package/dist/integrations/genomics/genomicPlatforms.d.ts.map +1 -0
- package/dist/integrations/genomics/genomicPlatforms.js +1079 -0
- package/dist/integrations/genomics/genomicPlatforms.js.map +1 -0
- package/package.json +52 -0
- package/src/bin/cure.ts +182 -0
- package/src/capabilities/cancerTreatmentCapability.ts +1161 -0
- package/src/capabilities/index.ts +2 -0
- package/src/compliance/hipaa.ts +1365 -0
- package/src/examples/cancerTreatmentDemo.ts +241 -0
- package/src/integrations/clinicalTrials/clinicalTrialsGov.ts +1143 -0
- package/src/integrations/ehr/fhir.ts +1304 -0
- package/src/integrations/genomics/genomicPlatforms.ts +1480 -0
- package/src/ml/outcomePredictor.ts +1301 -0
- package/src/safety/drugInteractions.ts +942 -0
- package/src/validation/retrospectiveValidator.ts +887 -0
|
@@ -0,0 +1,1365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HIPAA Compliance Layer
|
|
3
|
+
*
|
|
4
|
+
* Provides comprehensive HIPAA compliance features:
|
|
5
|
+
* - Audit logging (access, modification, disclosure)
|
|
6
|
+
* - Patient consent management
|
|
7
|
+
* - Data encryption (at rest and in transit)
|
|
8
|
+
* - Access control and authentication
|
|
9
|
+
* - Break-the-glass emergency access
|
|
10
|
+
* - Minimum necessary standard enforcement
|
|
11
|
+
* - Business Associate Agreement tracking
|
|
12
|
+
*
|
|
13
|
+
* IMPORTANT: This module implements the technical safeguards required by HIPAA.
|
|
14
|
+
* Administrative and physical safeguards must be implemented organizationally.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createHash, createCipheriv, createDecipheriv, randomBytes, scrypt } from 'crypto';
|
|
18
|
+
import { EventEmitter } from 'events';
|
|
19
|
+
|
|
20
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
21
|
+
// AUDIT LOG TYPES
|
|
22
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
23
|
+
|
|
24
|
+
export interface AuditLogEntry {
|
|
25
|
+
id: string;
|
|
26
|
+
timestamp: Date;
|
|
27
|
+
eventType: AuditEventType;
|
|
28
|
+
action: AuditAction;
|
|
29
|
+
outcome: 'success' | 'failure' | 'error';
|
|
30
|
+
|
|
31
|
+
// Actor information
|
|
32
|
+
actor: {
|
|
33
|
+
userId: string;
|
|
34
|
+
userName?: string;
|
|
35
|
+
role: string;
|
|
36
|
+
organization?: string;
|
|
37
|
+
ipAddress: string;
|
|
38
|
+
userAgent?: string;
|
|
39
|
+
sessionId?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Resource information
|
|
43
|
+
resource: {
|
|
44
|
+
type: 'patient' | 'record' | 'report' | 'order' | 'document' | 'system' | 'configuration';
|
|
45
|
+
id?: string;
|
|
46
|
+
patientId?: string;
|
|
47
|
+
description?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Additional context
|
|
51
|
+
details?: {
|
|
52
|
+
fieldsAccessed?: string[];
|
|
53
|
+
fieldsModified?: string[];
|
|
54
|
+
previousValues?: Record<string, any>;
|
|
55
|
+
newValues?: Record<string, any>;
|
|
56
|
+
query?: string;
|
|
57
|
+
reason?: string;
|
|
58
|
+
emergencyAccess?: boolean;
|
|
59
|
+
consentId?: string;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Security metadata
|
|
63
|
+
security: {
|
|
64
|
+
authMethod: 'password' | 'mfa' | 'sso' | 'certificate' | 'api-key' | 'oauth';
|
|
65
|
+
encryptionUsed: boolean;
|
|
66
|
+
integrityVerified: boolean;
|
|
67
|
+
accessLevel: 'normal' | 'elevated' | 'emergency';
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Hash for tamper detection
|
|
71
|
+
entryHash?: string;
|
|
72
|
+
previousEntryHash?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type AuditEventType =
|
|
76
|
+
| 'authentication'
|
|
77
|
+
| 'authorization'
|
|
78
|
+
| 'access'
|
|
79
|
+
| 'modification'
|
|
80
|
+
| 'disclosure'
|
|
81
|
+
| 'deletion'
|
|
82
|
+
| 'export'
|
|
83
|
+
| 'print'
|
|
84
|
+
| 'query'
|
|
85
|
+
| 'emergency-access'
|
|
86
|
+
| 'consent-change'
|
|
87
|
+
| 'system-event';
|
|
88
|
+
|
|
89
|
+
export type AuditAction =
|
|
90
|
+
| 'login' | 'logout' | 'login-failed' | 'session-timeout'
|
|
91
|
+
| 'view' | 'search' | 'download' | 'print' | 'export'
|
|
92
|
+
| 'create' | 'update' | 'delete' | 'restore'
|
|
93
|
+
| 'share' | 'transmit' | 'receive'
|
|
94
|
+
| 'grant-access' | 'revoke-access'
|
|
95
|
+
| 'consent-obtained' | 'consent-revoked'
|
|
96
|
+
| 'emergency-override' | 'break-the-glass';
|
|
97
|
+
|
|
98
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
99
|
+
// CONSENT MANAGEMENT TYPES
|
|
100
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
101
|
+
|
|
102
|
+
export interface PatientConsent {
|
|
103
|
+
id: string;
|
|
104
|
+
patientId: string;
|
|
105
|
+
consentType: ConsentType;
|
|
106
|
+
status: 'active' | 'revoked' | 'expired' | 'pending';
|
|
107
|
+
|
|
108
|
+
scope: {
|
|
109
|
+
dataCategories: DataCategory[];
|
|
110
|
+
purposes: ConsentPurpose[];
|
|
111
|
+
recipients?: string[];
|
|
112
|
+
excludedProviders?: string[];
|
|
113
|
+
excludedData?: string[];
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
validity: {
|
|
117
|
+
effectiveDate: Date;
|
|
118
|
+
expirationDate?: Date;
|
|
119
|
+
autoRenew?: boolean;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
capture: {
|
|
123
|
+
method: 'written' | 'electronic' | 'verbal' | 'implied';
|
|
124
|
+
documentId?: string;
|
|
125
|
+
witnessName?: string;
|
|
126
|
+
witnessDate?: Date;
|
|
127
|
+
ipAddress?: string;
|
|
128
|
+
deviceInfo?: string;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
audit: {
|
|
132
|
+
createdAt: Date;
|
|
133
|
+
createdBy: string;
|
|
134
|
+
modifiedAt?: Date;
|
|
135
|
+
modifiedBy?: string;
|
|
136
|
+
revokedAt?: Date;
|
|
137
|
+
revokedBy?: string;
|
|
138
|
+
revocationReason?: string;
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export type ConsentType =
|
|
143
|
+
| 'treatment' // Consent for treatment
|
|
144
|
+
| 'payment' // Consent for payment processing
|
|
145
|
+
| 'healthcare-ops' // Consent for healthcare operations
|
|
146
|
+
| 'research' // Consent for research use
|
|
147
|
+
| 'marketing' // Consent for marketing communications
|
|
148
|
+
| 'disclosure' // Consent for specific disclosure
|
|
149
|
+
| 'genetic-testing' // Consent for genetic testing
|
|
150
|
+
| 'clinical-trial' // Consent for clinical trial participation
|
|
151
|
+
| 'data-sharing' // Consent for data sharing with third parties
|
|
152
|
+
| 'hie' // Consent for Health Information Exchange
|
|
153
|
+
| 'psychotherapy-notes' // Special consent for psychotherapy notes
|
|
154
|
+
| 'substance-abuse'; // 42 CFR Part 2 consent for substance abuse records
|
|
155
|
+
|
|
156
|
+
export type DataCategory =
|
|
157
|
+
| 'demographics'
|
|
158
|
+
| 'diagnoses'
|
|
159
|
+
| 'medications'
|
|
160
|
+
| 'lab-results'
|
|
161
|
+
| 'imaging'
|
|
162
|
+
| 'procedures'
|
|
163
|
+
| 'genomics'
|
|
164
|
+
| 'mental-health'
|
|
165
|
+
| 'substance-abuse'
|
|
166
|
+
| 'hiv-status'
|
|
167
|
+
| 'sexual-health'
|
|
168
|
+
| 'domestic-violence'
|
|
169
|
+
| 'psychotherapy-notes'
|
|
170
|
+
| 'billing';
|
|
171
|
+
|
|
172
|
+
export type ConsentPurpose =
|
|
173
|
+
| 'treatment'
|
|
174
|
+
| 'payment'
|
|
175
|
+
| 'healthcare-operations'
|
|
176
|
+
| 'research'
|
|
177
|
+
| 'public-health'
|
|
178
|
+
| 'legal'
|
|
179
|
+
| 'insurance'
|
|
180
|
+
| 'marketing'
|
|
181
|
+
| 'fundraising'
|
|
182
|
+
| 'care-coordination'
|
|
183
|
+
| 'quality-improvement';
|
|
184
|
+
|
|
185
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
186
|
+
// ACCESS CONTROL TYPES
|
|
187
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
188
|
+
|
|
189
|
+
export interface AccessPolicy {
|
|
190
|
+
id: string;
|
|
191
|
+
name: string;
|
|
192
|
+
description?: string;
|
|
193
|
+
|
|
194
|
+
subjects: {
|
|
195
|
+
roles?: string[];
|
|
196
|
+
users?: string[];
|
|
197
|
+
organizations?: string[];
|
|
198
|
+
departments?: string[];
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
resources: {
|
|
202
|
+
types?: string[];
|
|
203
|
+
patientIds?: string[];
|
|
204
|
+
dataCategories?: DataCategory[];
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
permissions: {
|
|
208
|
+
actions: ('read' | 'write' | 'delete' | 'share' | 'print' | 'export')[];
|
|
209
|
+
conditions?: AccessCondition[];
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
priority: number;
|
|
213
|
+
enabled: boolean;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface AccessCondition {
|
|
217
|
+
type: 'time-of-day' | 'ip-range' | 'location' | 'mfa-required' | 'relationship' | 'purpose' | 'emergency-only';
|
|
218
|
+
parameters: Record<string, any>;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface AccessDecision {
|
|
222
|
+
allowed: boolean;
|
|
223
|
+
policy?: string;
|
|
224
|
+
reason: string;
|
|
225
|
+
conditions?: string[];
|
|
226
|
+
requiresElevation?: boolean;
|
|
227
|
+
auditRequired: boolean;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
231
|
+
// ENCRYPTION TYPES
|
|
232
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
233
|
+
|
|
234
|
+
export interface EncryptionConfig {
|
|
235
|
+
algorithm: 'aes-256-gcm' | 'aes-256-cbc' | 'chacha20-poly1305';
|
|
236
|
+
keyDerivation: 'scrypt' | 'pbkdf2' | 'argon2';
|
|
237
|
+
keyRotationDays: number;
|
|
238
|
+
fieldLevelEncryption: boolean;
|
|
239
|
+
encryptedFields?: string[];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export interface EncryptedData {
|
|
243
|
+
ciphertext: string;
|
|
244
|
+
iv: string;
|
|
245
|
+
authTag?: string;
|
|
246
|
+
keyId: string;
|
|
247
|
+
algorithm: string;
|
|
248
|
+
encryptedAt: Date;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export interface DataMaskingConfig {
|
|
252
|
+
ssnPattern: 'full' | 'last4' | 'hidden';
|
|
253
|
+
dobPattern: 'full' | 'year-only' | 'age-only' | 'hidden';
|
|
254
|
+
phonePattern: 'full' | 'last4' | 'hidden';
|
|
255
|
+
addressPattern: 'full' | 'city-state' | 'zip-only' | 'hidden';
|
|
256
|
+
mrnPattern: 'full' | 'last4' | 'hidden';
|
|
257
|
+
genomicPattern: 'full' | 'summary' | 'hidden';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
261
|
+
// HIPAA COMPLIANCE SERVICE
|
|
262
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
263
|
+
|
|
264
|
+
export class HIPAAComplianceService extends EventEmitter {
|
|
265
|
+
private auditStore: AuditLogEntry[] = [];
|
|
266
|
+
private consents: Map<string, PatientConsent[]> = new Map();
|
|
267
|
+
private accessPolicies: AccessPolicy[] = [];
|
|
268
|
+
private encryptionKeys: Map<string, { key: Buffer; createdAt: Date; active: boolean }> = new Map();
|
|
269
|
+
private activeKeyId: string;
|
|
270
|
+
private config: {
|
|
271
|
+
encryption: EncryptionConfig;
|
|
272
|
+
masking: DataMaskingConfig;
|
|
273
|
+
auditRetentionDays: number;
|
|
274
|
+
emergencyAccessEnabled: boolean;
|
|
275
|
+
};
|
|
276
|
+
private lastEntryHash?: string;
|
|
277
|
+
|
|
278
|
+
constructor(config?: Partial<{
|
|
279
|
+
encryption: Partial<EncryptionConfig>;
|
|
280
|
+
masking: Partial<DataMaskingConfig>;
|
|
281
|
+
auditRetentionDays: number;
|
|
282
|
+
emergencyAccessEnabled: boolean;
|
|
283
|
+
}>) {
|
|
284
|
+
super();
|
|
285
|
+
|
|
286
|
+
this.config = {
|
|
287
|
+
encryption: {
|
|
288
|
+
algorithm: 'aes-256-gcm',
|
|
289
|
+
keyDerivation: 'scrypt',
|
|
290
|
+
keyRotationDays: 90,
|
|
291
|
+
fieldLevelEncryption: true,
|
|
292
|
+
encryptedFields: ['ssn', 'dob', 'address', 'phone', 'genomics', 'mentalHealth'],
|
|
293
|
+
...config?.encryption
|
|
294
|
+
},
|
|
295
|
+
masking: {
|
|
296
|
+
ssnPattern: 'last4',
|
|
297
|
+
dobPattern: 'age-only',
|
|
298
|
+
phonePattern: 'last4',
|
|
299
|
+
addressPattern: 'city-state',
|
|
300
|
+
mrnPattern: 'full',
|
|
301
|
+
genomicPattern: 'summary',
|
|
302
|
+
...config?.masking
|
|
303
|
+
},
|
|
304
|
+
auditRetentionDays: config?.auditRetentionDays || 2190, // 6 years per HIPAA
|
|
305
|
+
emergencyAccessEnabled: config?.emergencyAccessEnabled ?? true
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// Initialize encryption key
|
|
309
|
+
this.activeKeyId = this.generateKeyId();
|
|
310
|
+
this.initializeEncryptionKey();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
314
|
+
// AUDIT LOGGING
|
|
315
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Log an audit event
|
|
319
|
+
*/
|
|
320
|
+
async logAuditEvent(entry: Omit<AuditLogEntry, 'id' | 'timestamp' | 'entryHash' | 'previousEntryHash'>): Promise<string> {
|
|
321
|
+
const id = this.generateAuditId();
|
|
322
|
+
const timestamp = new Date();
|
|
323
|
+
|
|
324
|
+
// Create the entry
|
|
325
|
+
const fullEntry: AuditLogEntry = {
|
|
326
|
+
id,
|
|
327
|
+
timestamp,
|
|
328
|
+
...entry,
|
|
329
|
+
previousEntryHash: this.lastEntryHash
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
// Calculate hash for tamper detection
|
|
333
|
+
fullEntry.entryHash = this.calculateEntryHash(fullEntry);
|
|
334
|
+
this.lastEntryHash = fullEntry.entryHash;
|
|
335
|
+
|
|
336
|
+
// Store the entry
|
|
337
|
+
this.auditStore.push(fullEntry);
|
|
338
|
+
|
|
339
|
+
// Emit event for external handlers
|
|
340
|
+
this.emit('audit-event', fullEntry);
|
|
341
|
+
|
|
342
|
+
// Check for suspicious patterns
|
|
343
|
+
await this.detectSuspiciousActivity(fullEntry);
|
|
344
|
+
|
|
345
|
+
return id;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Log PHI access event
|
|
350
|
+
*/
|
|
351
|
+
async logPHIAccess(params: {
|
|
352
|
+
userId: string;
|
|
353
|
+
userName?: string;
|
|
354
|
+
role: string;
|
|
355
|
+
ipAddress: string;
|
|
356
|
+
patientId: string;
|
|
357
|
+
action: 'view' | 'download' | 'print' | 'export';
|
|
358
|
+
resourceType: AuditLogEntry['resource']['type'];
|
|
359
|
+
resourceId?: string;
|
|
360
|
+
fieldsAccessed?: string[];
|
|
361
|
+
reason?: string;
|
|
362
|
+
emergencyAccess?: boolean;
|
|
363
|
+
consentId?: string;
|
|
364
|
+
}): Promise<string> {
|
|
365
|
+
return this.logAuditEvent({
|
|
366
|
+
eventType: params.emergencyAccess ? 'emergency-access' : 'access',
|
|
367
|
+
action: params.action,
|
|
368
|
+
outcome: 'success',
|
|
369
|
+
actor: {
|
|
370
|
+
userId: params.userId,
|
|
371
|
+
userName: params.userName,
|
|
372
|
+
role: params.role,
|
|
373
|
+
ipAddress: params.ipAddress
|
|
374
|
+
},
|
|
375
|
+
resource: {
|
|
376
|
+
type: params.resourceType,
|
|
377
|
+
id: params.resourceId,
|
|
378
|
+
patientId: params.patientId
|
|
379
|
+
},
|
|
380
|
+
details: {
|
|
381
|
+
fieldsAccessed: params.fieldsAccessed,
|
|
382
|
+
reason: params.reason,
|
|
383
|
+
emergencyAccess: params.emergencyAccess,
|
|
384
|
+
consentId: params.consentId
|
|
385
|
+
},
|
|
386
|
+
security: {
|
|
387
|
+
authMethod: 'mfa', // Assuming MFA for PHI access
|
|
388
|
+
encryptionUsed: true,
|
|
389
|
+
integrityVerified: true,
|
|
390
|
+
accessLevel: params.emergencyAccess ? 'emergency' : 'normal'
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Log PHI modification event
|
|
397
|
+
*/
|
|
398
|
+
async logPHIModification(params: {
|
|
399
|
+
userId: string;
|
|
400
|
+
role: string;
|
|
401
|
+
ipAddress: string;
|
|
402
|
+
patientId: string;
|
|
403
|
+
action: 'create' | 'update' | 'delete';
|
|
404
|
+
resourceType: AuditLogEntry['resource']['type'];
|
|
405
|
+
resourceId?: string;
|
|
406
|
+
fieldsModified?: string[];
|
|
407
|
+
previousValues?: Record<string, any>;
|
|
408
|
+
newValues?: Record<string, any>;
|
|
409
|
+
reason?: string;
|
|
410
|
+
}): Promise<string> {
|
|
411
|
+
return this.logAuditEvent({
|
|
412
|
+
eventType: 'modification',
|
|
413
|
+
action: params.action,
|
|
414
|
+
outcome: 'success',
|
|
415
|
+
actor: {
|
|
416
|
+
userId: params.userId,
|
|
417
|
+
role: params.role,
|
|
418
|
+
ipAddress: params.ipAddress
|
|
419
|
+
},
|
|
420
|
+
resource: {
|
|
421
|
+
type: params.resourceType,
|
|
422
|
+
id: params.resourceId,
|
|
423
|
+
patientId: params.patientId
|
|
424
|
+
},
|
|
425
|
+
details: {
|
|
426
|
+
fieldsModified: params.fieldsModified,
|
|
427
|
+
previousValues: params.previousValues,
|
|
428
|
+
newValues: params.newValues,
|
|
429
|
+
reason: params.reason
|
|
430
|
+
},
|
|
431
|
+
security: {
|
|
432
|
+
authMethod: 'mfa',
|
|
433
|
+
encryptionUsed: true,
|
|
434
|
+
integrityVerified: true,
|
|
435
|
+
accessLevel: 'normal'
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Log authentication event
|
|
442
|
+
*/
|
|
443
|
+
async logAuthentication(params: {
|
|
444
|
+
userId: string;
|
|
445
|
+
ipAddress: string;
|
|
446
|
+
action: 'login' | 'logout' | 'login-failed' | 'session-timeout';
|
|
447
|
+
authMethod: AuditLogEntry['security']['authMethod'];
|
|
448
|
+
success: boolean;
|
|
449
|
+
failureReason?: string;
|
|
450
|
+
}): Promise<string> {
|
|
451
|
+
return this.logAuditEvent({
|
|
452
|
+
eventType: 'authentication',
|
|
453
|
+
action: params.action,
|
|
454
|
+
outcome: params.success ? 'success' : 'failure',
|
|
455
|
+
actor: {
|
|
456
|
+
userId: params.userId,
|
|
457
|
+
role: 'unknown',
|
|
458
|
+
ipAddress: params.ipAddress
|
|
459
|
+
},
|
|
460
|
+
resource: {
|
|
461
|
+
type: 'system'
|
|
462
|
+
},
|
|
463
|
+
details: params.failureReason ? { reason: params.failureReason } : undefined,
|
|
464
|
+
security: {
|
|
465
|
+
authMethod: params.authMethod,
|
|
466
|
+
encryptionUsed: true,
|
|
467
|
+
integrityVerified: true,
|
|
468
|
+
accessLevel: 'normal'
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Query audit logs
|
|
475
|
+
*/
|
|
476
|
+
queryAuditLogs(params: {
|
|
477
|
+
startDate?: Date;
|
|
478
|
+
endDate?: Date;
|
|
479
|
+
patientId?: string;
|
|
480
|
+
userId?: string;
|
|
481
|
+
eventType?: AuditEventType;
|
|
482
|
+
action?: AuditAction;
|
|
483
|
+
outcome?: 'success' | 'failure';
|
|
484
|
+
limit?: number;
|
|
485
|
+
offset?: number;
|
|
486
|
+
}): { entries: AuditLogEntry[]; total: number } {
|
|
487
|
+
let filtered = [...this.auditStore];
|
|
488
|
+
|
|
489
|
+
if (params.startDate) {
|
|
490
|
+
filtered = filtered.filter(e => e.timestamp >= params.startDate!);
|
|
491
|
+
}
|
|
492
|
+
if (params.endDate) {
|
|
493
|
+
filtered = filtered.filter(e => e.timestamp <= params.endDate!);
|
|
494
|
+
}
|
|
495
|
+
if (params.patientId) {
|
|
496
|
+
filtered = filtered.filter(e => e.resource.patientId === params.patientId);
|
|
497
|
+
}
|
|
498
|
+
if (params.userId) {
|
|
499
|
+
filtered = filtered.filter(e => e.actor.userId === params.userId);
|
|
500
|
+
}
|
|
501
|
+
if (params.eventType) {
|
|
502
|
+
filtered = filtered.filter(e => e.eventType === params.eventType);
|
|
503
|
+
}
|
|
504
|
+
if (params.action) {
|
|
505
|
+
filtered = filtered.filter(e => e.action === params.action);
|
|
506
|
+
}
|
|
507
|
+
if (params.outcome) {
|
|
508
|
+
filtered = filtered.filter(e => e.outcome === params.outcome);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Sort by timestamp descending
|
|
512
|
+
filtered.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
|
513
|
+
|
|
514
|
+
const total = filtered.length;
|
|
515
|
+
const offset = params.offset || 0;
|
|
516
|
+
const limit = params.limit || 100;
|
|
517
|
+
|
|
518
|
+
return {
|
|
519
|
+
entries: filtered.slice(offset, offset + limit),
|
|
520
|
+
total
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Get accounting of disclosures for a patient (HIPAA requirement)
|
|
526
|
+
*/
|
|
527
|
+
getAccountingOfDisclosures(patientId: string, startDate?: Date, endDate?: Date): AuditLogEntry[] {
|
|
528
|
+
return this.auditStore.filter(entry =>
|
|
529
|
+
entry.resource.patientId === patientId &&
|
|
530
|
+
entry.eventType === 'disclosure' &&
|
|
531
|
+
(!startDate || entry.timestamp >= startDate) &&
|
|
532
|
+
(!endDate || entry.timestamp <= endDate)
|
|
533
|
+
).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Verify audit log integrity
|
|
538
|
+
*/
|
|
539
|
+
verifyAuditLogIntegrity(): { valid: boolean; errors: string[] } {
|
|
540
|
+
const errors: string[] = [];
|
|
541
|
+
|
|
542
|
+
for (let i = 0; i < this.auditStore.length; i++) {
|
|
543
|
+
const entry = this.auditStore[i];
|
|
544
|
+
|
|
545
|
+
// Verify hash
|
|
546
|
+
const { entryHash: _hash, ...entryWithoutHash } = entry;
|
|
547
|
+
const calculatedHash = this.calculateEntryHash(entryWithoutHash);
|
|
548
|
+
|
|
549
|
+
if (calculatedHash !== entry.entryHash) {
|
|
550
|
+
errors.push(`Entry ${entry.id} has been tampered with (hash mismatch)`);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Verify chain
|
|
554
|
+
if (i > 0 && entry.previousEntryHash !== this.auditStore[i - 1].entryHash) {
|
|
555
|
+
errors.push(`Entry ${entry.id} has broken chain link`);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return {
|
|
560
|
+
valid: errors.length === 0,
|
|
561
|
+
errors
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
566
|
+
// CONSENT MANAGEMENT
|
|
567
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Record a patient consent
|
|
571
|
+
*/
|
|
572
|
+
async recordConsent(consent: Omit<PatientConsent, 'id' | 'audit'>): Promise<string> {
|
|
573
|
+
const id = this.generateConsentId();
|
|
574
|
+
const now = new Date();
|
|
575
|
+
|
|
576
|
+
const fullConsent: PatientConsent = {
|
|
577
|
+
id,
|
|
578
|
+
...consent,
|
|
579
|
+
audit: {
|
|
580
|
+
createdAt: now,
|
|
581
|
+
createdBy: consent.capture.method === 'electronic' ? 'system' : 'staff'
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
// Store consent
|
|
586
|
+
const patientConsents = this.consents.get(consent.patientId) || [];
|
|
587
|
+
patientConsents.push(fullConsent);
|
|
588
|
+
this.consents.set(consent.patientId, patientConsents);
|
|
589
|
+
|
|
590
|
+
// Log the consent capture
|
|
591
|
+
await this.logAuditEvent({
|
|
592
|
+
eventType: 'consent-change',
|
|
593
|
+
action: 'consent-obtained',
|
|
594
|
+
outcome: 'success',
|
|
595
|
+
actor: {
|
|
596
|
+
userId: fullConsent.audit.createdBy,
|
|
597
|
+
role: 'system',
|
|
598
|
+
ipAddress: consent.capture.ipAddress || 'unknown'
|
|
599
|
+
},
|
|
600
|
+
resource: {
|
|
601
|
+
type: 'patient',
|
|
602
|
+
patientId: consent.patientId,
|
|
603
|
+
description: `${consent.consentType} consent`
|
|
604
|
+
},
|
|
605
|
+
details: {
|
|
606
|
+
consentId: id
|
|
607
|
+
},
|
|
608
|
+
security: {
|
|
609
|
+
authMethod: 'password',
|
|
610
|
+
encryptionUsed: true,
|
|
611
|
+
integrityVerified: true,
|
|
612
|
+
accessLevel: 'normal'
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
this.emit('consent-recorded', fullConsent);
|
|
617
|
+
|
|
618
|
+
return id;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Revoke a patient consent
|
|
623
|
+
*/
|
|
624
|
+
async revokeConsent(consentId: string, revokedBy: string, reason?: string): Promise<boolean> {
|
|
625
|
+
for (const [patientId, consents] of this.consents) {
|
|
626
|
+
const consent = consents.find(c => c.id === consentId);
|
|
627
|
+
if (consent) {
|
|
628
|
+
consent.status = 'revoked';
|
|
629
|
+
consent.audit.revokedAt = new Date();
|
|
630
|
+
consent.audit.revokedBy = revokedBy;
|
|
631
|
+
consent.audit.revocationReason = reason;
|
|
632
|
+
|
|
633
|
+
// Log the revocation
|
|
634
|
+
await this.logAuditEvent({
|
|
635
|
+
eventType: 'consent-change',
|
|
636
|
+
action: 'consent-revoked',
|
|
637
|
+
outcome: 'success',
|
|
638
|
+
actor: {
|
|
639
|
+
userId: revokedBy,
|
|
640
|
+
role: 'patient',
|
|
641
|
+
ipAddress: 'unknown'
|
|
642
|
+
},
|
|
643
|
+
resource: {
|
|
644
|
+
type: 'patient',
|
|
645
|
+
patientId,
|
|
646
|
+
description: `${consent.consentType} consent revoked`
|
|
647
|
+
},
|
|
648
|
+
details: {
|
|
649
|
+
consentId,
|
|
650
|
+
reason
|
|
651
|
+
},
|
|
652
|
+
security: {
|
|
653
|
+
authMethod: 'password',
|
|
654
|
+
encryptionUsed: true,
|
|
655
|
+
integrityVerified: true,
|
|
656
|
+
accessLevel: 'normal'
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
this.emit('consent-revoked', consent);
|
|
661
|
+
|
|
662
|
+
return true;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return false;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Check if a patient has consented to a specific use
|
|
670
|
+
*/
|
|
671
|
+
checkConsent(patientId: string, purpose: ConsentPurpose, dataCategory?: DataCategory): {
|
|
672
|
+
hasConsent: boolean;
|
|
673
|
+
consentId?: string;
|
|
674
|
+
restrictions?: string[];
|
|
675
|
+
} {
|
|
676
|
+
const patientConsents = this.consents.get(patientId) || [];
|
|
677
|
+
|
|
678
|
+
// Find active consent that covers the purpose
|
|
679
|
+
const validConsent = patientConsents.find(c =>
|
|
680
|
+
c.status === 'active' &&
|
|
681
|
+
c.scope.purposes.includes(purpose) &&
|
|
682
|
+
(!dataCategory || c.scope.dataCategories.includes(dataCategory)) &&
|
|
683
|
+
(!c.validity.expirationDate || c.validity.expirationDate > new Date())
|
|
684
|
+
);
|
|
685
|
+
|
|
686
|
+
if (validConsent) {
|
|
687
|
+
return {
|
|
688
|
+
hasConsent: true,
|
|
689
|
+
consentId: validConsent.id,
|
|
690
|
+
restrictions: validConsent.scope.excludedData
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Check if TPO (Treatment, Payment, Operations) - may not require explicit consent
|
|
695
|
+
if (['treatment', 'payment', 'healthcare-operations'].includes(purpose)) {
|
|
696
|
+
// TPO typically covered by Notice of Privacy Practices
|
|
697
|
+
return {
|
|
698
|
+
hasConsent: true,
|
|
699
|
+
restrictions: []
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return {
|
|
704
|
+
hasConsent: false
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Get all consents for a patient
|
|
710
|
+
*/
|
|
711
|
+
getPatientConsents(patientId: string): PatientConsent[] {
|
|
712
|
+
return this.consents.get(patientId) || [];
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
716
|
+
// ACCESS CONTROL
|
|
717
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Add an access policy
|
|
721
|
+
*/
|
|
722
|
+
addAccessPolicy(policy: Omit<AccessPolicy, 'id'>): string {
|
|
723
|
+
const id = this.generatePolicyId();
|
|
724
|
+
const fullPolicy: AccessPolicy = { id, ...policy };
|
|
725
|
+
this.accessPolicies.push(fullPolicy);
|
|
726
|
+
this.accessPolicies.sort((a, b) => b.priority - a.priority);
|
|
727
|
+
return id;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Check if access should be allowed
|
|
732
|
+
*/
|
|
733
|
+
checkAccess(request: {
|
|
734
|
+
userId: string;
|
|
735
|
+
role: string;
|
|
736
|
+
organization?: string;
|
|
737
|
+
action: 'read' | 'write' | 'delete' | 'share' | 'print' | 'export';
|
|
738
|
+
resourceType: string;
|
|
739
|
+
patientId?: string;
|
|
740
|
+
dataCategory?: DataCategory;
|
|
741
|
+
purpose?: ConsentPurpose;
|
|
742
|
+
ipAddress?: string;
|
|
743
|
+
time?: Date;
|
|
744
|
+
emergencyAccess?: boolean;
|
|
745
|
+
}): AccessDecision {
|
|
746
|
+
const time = request.time || new Date();
|
|
747
|
+
|
|
748
|
+
// Emergency access bypass (break-the-glass)
|
|
749
|
+
if (request.emergencyAccess && this.config.emergencyAccessEnabled) {
|
|
750
|
+
return {
|
|
751
|
+
allowed: true,
|
|
752
|
+
reason: 'Emergency access granted (break-the-glass)',
|
|
753
|
+
conditions: ['Must document emergency reason', 'Subject to post-hoc review'],
|
|
754
|
+
auditRequired: true
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Check consent if accessing patient data
|
|
759
|
+
if (request.patientId && request.purpose) {
|
|
760
|
+
const consentCheck = this.checkConsent(
|
|
761
|
+
request.patientId,
|
|
762
|
+
request.purpose,
|
|
763
|
+
request.dataCategory
|
|
764
|
+
);
|
|
765
|
+
|
|
766
|
+
if (!consentCheck.hasConsent) {
|
|
767
|
+
return {
|
|
768
|
+
allowed: false,
|
|
769
|
+
reason: `No consent for ${request.purpose} purpose`,
|
|
770
|
+
auditRequired: true
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Evaluate policies in priority order
|
|
776
|
+
for (const policy of this.accessPolicies) {
|
|
777
|
+
if (!policy.enabled) continue;
|
|
778
|
+
|
|
779
|
+
// Check if policy applies to this subject
|
|
780
|
+
const subjectMatch =
|
|
781
|
+
(!policy.subjects.roles || policy.subjects.roles.includes(request.role)) &&
|
|
782
|
+
(!policy.subjects.users || policy.subjects.users.includes(request.userId)) &&
|
|
783
|
+
(!policy.subjects.organizations || policy.subjects.organizations.includes(request.organization || ''));
|
|
784
|
+
|
|
785
|
+
if (!subjectMatch) continue;
|
|
786
|
+
|
|
787
|
+
// Check if policy applies to this resource
|
|
788
|
+
const resourceMatch =
|
|
789
|
+
(!policy.resources.types || policy.resources.types.includes(request.resourceType)) &&
|
|
790
|
+
(!policy.resources.patientIds || !request.patientId || policy.resources.patientIds.includes(request.patientId)) &&
|
|
791
|
+
(!policy.resources.dataCategories || !request.dataCategory || policy.resources.dataCategories.includes(request.dataCategory));
|
|
792
|
+
|
|
793
|
+
if (!resourceMatch) continue;
|
|
794
|
+
|
|
795
|
+
// Check if action is permitted
|
|
796
|
+
if (!policy.permissions.actions.includes(request.action)) {
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Evaluate conditions
|
|
801
|
+
const conditionResults = this.evaluateConditions(policy.permissions.conditions || [], request, time);
|
|
802
|
+
|
|
803
|
+
if (conditionResults.allMet) {
|
|
804
|
+
return {
|
|
805
|
+
allowed: true,
|
|
806
|
+
policy: policy.id,
|
|
807
|
+
reason: `Access granted by policy: ${policy.name}`,
|
|
808
|
+
conditions: conditionResults.messages,
|
|
809
|
+
auditRequired: true
|
|
810
|
+
};
|
|
811
|
+
} else if (conditionResults.requiresElevation) {
|
|
812
|
+
return {
|
|
813
|
+
allowed: false,
|
|
814
|
+
policy: policy.id,
|
|
815
|
+
reason: 'Access requires additional verification',
|
|
816
|
+
requiresElevation: true,
|
|
817
|
+
conditions: conditionResults.messages,
|
|
818
|
+
auditRequired: true
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Default deny
|
|
824
|
+
return {
|
|
825
|
+
allowed: false,
|
|
826
|
+
reason: 'No matching policy found - access denied by default',
|
|
827
|
+
auditRequired: true
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
private evaluateConditions(
|
|
832
|
+
conditions: AccessCondition[],
|
|
833
|
+
request: any,
|
|
834
|
+
time: Date
|
|
835
|
+
): { allMet: boolean; requiresElevation: boolean; messages: string[] } {
|
|
836
|
+
const messages: string[] = [];
|
|
837
|
+
let requiresElevation = false;
|
|
838
|
+
|
|
839
|
+
for (const condition of conditions) {
|
|
840
|
+
switch (condition.type) {
|
|
841
|
+
case 'time-of-day':
|
|
842
|
+
const hour = time.getHours();
|
|
843
|
+
const startHour = condition.parameters.startHour || 0;
|
|
844
|
+
const endHour = condition.parameters.endHour || 24;
|
|
845
|
+
if (hour < startHour || hour >= endHour) {
|
|
846
|
+
messages.push(`Access outside permitted hours (${startHour}:00 - ${endHour}:00)`);
|
|
847
|
+
return { allMet: false, requiresElevation: false, messages };
|
|
848
|
+
}
|
|
849
|
+
break;
|
|
850
|
+
|
|
851
|
+
case 'ip-range':
|
|
852
|
+
// Simplified IP check - would need proper CIDR matching in production
|
|
853
|
+
const allowedRanges = condition.parameters.ranges as string[];
|
|
854
|
+
if (request.ipAddress && !allowedRanges.some(r => request.ipAddress.startsWith(r))) {
|
|
855
|
+
messages.push('Access from unauthorized network location');
|
|
856
|
+
return { allMet: false, requiresElevation: false, messages };
|
|
857
|
+
}
|
|
858
|
+
break;
|
|
859
|
+
|
|
860
|
+
case 'mfa-required':
|
|
861
|
+
messages.push('Multi-factor authentication required');
|
|
862
|
+
requiresElevation = true;
|
|
863
|
+
break;
|
|
864
|
+
|
|
865
|
+
case 'purpose':
|
|
866
|
+
const allowedPurposes = condition.parameters.purposes as ConsentPurpose[];
|
|
867
|
+
if (!request.purpose || !allowedPurposes.includes(request.purpose)) {
|
|
868
|
+
messages.push(`Purpose '${request.purpose}' not permitted`);
|
|
869
|
+
return { allMet: false, requiresElevation: false, messages };
|
|
870
|
+
}
|
|
871
|
+
break;
|
|
872
|
+
|
|
873
|
+
case 'emergency-only':
|
|
874
|
+
if (!request.emergencyAccess) {
|
|
875
|
+
messages.push('Access restricted to emergency situations');
|
|
876
|
+
return { allMet: false, requiresElevation: false, messages };
|
|
877
|
+
}
|
|
878
|
+
break;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
return {
|
|
883
|
+
allMet: !requiresElevation,
|
|
884
|
+
requiresElevation,
|
|
885
|
+
messages
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
890
|
+
// ENCRYPTION
|
|
891
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Encrypt sensitive data
|
|
895
|
+
*/
|
|
896
|
+
async encryptData(data: string | Buffer, context?: string): Promise<EncryptedData> {
|
|
897
|
+
const keyEntry = this.encryptionKeys.get(this.activeKeyId);
|
|
898
|
+
if (!keyEntry) {
|
|
899
|
+
throw new Error('No active encryption key');
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const iv = randomBytes(16);
|
|
903
|
+
const algorithm = this.config.encryption.algorithm;
|
|
904
|
+
|
|
905
|
+
if (algorithm === 'aes-256-gcm') {
|
|
906
|
+
const cipher = createCipheriv('aes-256-gcm', keyEntry.key, iv);
|
|
907
|
+
if (context) {
|
|
908
|
+
cipher.setAAD(Buffer.from(context));
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const dataBuffer = typeof data === 'string' ? Buffer.from(data, 'utf8') : data;
|
|
912
|
+
const encrypted = Buffer.concat([cipher.update(dataBuffer), cipher.final()]);
|
|
913
|
+
const authTag = cipher.getAuthTag();
|
|
914
|
+
|
|
915
|
+
return {
|
|
916
|
+
ciphertext: encrypted.toString('base64'),
|
|
917
|
+
iv: iv.toString('base64'),
|
|
918
|
+
authTag: authTag.toString('base64'),
|
|
919
|
+
keyId: this.activeKeyId,
|
|
920
|
+
algorithm,
|
|
921
|
+
encryptedAt: new Date()
|
|
922
|
+
};
|
|
923
|
+
} else {
|
|
924
|
+
// AES-256-CBC fallback
|
|
925
|
+
const cipher = createCipheriv('aes-256-cbc', keyEntry.key, iv);
|
|
926
|
+
const dataBuffer = typeof data === 'string' ? Buffer.from(data, 'utf8') : data;
|
|
927
|
+
const encrypted = Buffer.concat([cipher.update(dataBuffer), cipher.final()]);
|
|
928
|
+
|
|
929
|
+
return {
|
|
930
|
+
ciphertext: encrypted.toString('base64'),
|
|
931
|
+
iv: iv.toString('base64'),
|
|
932
|
+
keyId: this.activeKeyId,
|
|
933
|
+
algorithm: 'aes-256-cbc',
|
|
934
|
+
encryptedAt: new Date()
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Decrypt data
|
|
941
|
+
*/
|
|
942
|
+
async decryptData(encrypted: EncryptedData, context?: string): Promise<Buffer> {
|
|
943
|
+
const keyEntry = this.encryptionKeys.get(encrypted.keyId);
|
|
944
|
+
if (!keyEntry) {
|
|
945
|
+
throw new Error(`Encryption key ${encrypted.keyId} not found`);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const iv = Buffer.from(encrypted.iv, 'base64');
|
|
949
|
+
const ciphertext = Buffer.from(encrypted.ciphertext, 'base64');
|
|
950
|
+
|
|
951
|
+
if (encrypted.algorithm === 'aes-256-gcm') {
|
|
952
|
+
const decipher = createDecipheriv('aes-256-gcm', keyEntry.key, iv);
|
|
953
|
+
if (encrypted.authTag) {
|
|
954
|
+
decipher.setAuthTag(Buffer.from(encrypted.authTag, 'base64'));
|
|
955
|
+
}
|
|
956
|
+
if (context) {
|
|
957
|
+
decipher.setAAD(Buffer.from(context));
|
|
958
|
+
}
|
|
959
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
960
|
+
} else {
|
|
961
|
+
const decipher = createDecipheriv('aes-256-cbc', keyEntry.key, iv);
|
|
962
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Encrypt specific PHI fields in an object
|
|
968
|
+
*/
|
|
969
|
+
async encryptPHI<T extends Record<string, any>>(data: T): Promise<T & { _encrypted: Record<string, EncryptedData> }> {
|
|
970
|
+
const encryptedFields: Record<string, EncryptedData> = {};
|
|
971
|
+
const result = { ...data } as T & { _encrypted: Record<string, EncryptedData> };
|
|
972
|
+
|
|
973
|
+
for (const field of this.config.encryption.encryptedFields || []) {
|
|
974
|
+
if (data[field] !== undefined) {
|
|
975
|
+
encryptedFields[field] = await this.encryptData(JSON.stringify(data[field]), field);
|
|
976
|
+
(result as any)[field] = '[ENCRYPTED]';
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
result._encrypted = encryptedFields;
|
|
981
|
+
return result;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Decrypt PHI fields in an object
|
|
986
|
+
*/
|
|
987
|
+
async decryptPHI<T extends Record<string, any>>(data: T & { _encrypted?: Record<string, EncryptedData> }): Promise<T> {
|
|
988
|
+
if (!data._encrypted) return data;
|
|
989
|
+
|
|
990
|
+
const result = { ...data };
|
|
991
|
+
for (const [field, encrypted] of Object.entries(data._encrypted)) {
|
|
992
|
+
const decrypted = await this.decryptData(encrypted, field);
|
|
993
|
+
(result as any)[field] = JSON.parse(decrypted.toString('utf8'));
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
delete (result as any)._encrypted;
|
|
997
|
+
return result;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Rotate encryption keys
|
|
1002
|
+
*/
|
|
1003
|
+
async rotateEncryptionKey(): Promise<string> {
|
|
1004
|
+
// Mark current key as inactive
|
|
1005
|
+
const currentKey = this.encryptionKeys.get(this.activeKeyId);
|
|
1006
|
+
if (currentKey) {
|
|
1007
|
+
currentKey.active = false;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Generate new key
|
|
1011
|
+
const newKeyId = this.generateKeyId();
|
|
1012
|
+
this.initializeEncryptionKey(newKeyId);
|
|
1013
|
+
this.activeKeyId = newKeyId;
|
|
1014
|
+
|
|
1015
|
+
// Log key rotation
|
|
1016
|
+
await this.logAuditEvent({
|
|
1017
|
+
eventType: 'system-event',
|
|
1018
|
+
action: 'update',
|
|
1019
|
+
outcome: 'success',
|
|
1020
|
+
actor: {
|
|
1021
|
+
userId: 'system',
|
|
1022
|
+
role: 'system',
|
|
1023
|
+
ipAddress: 'localhost'
|
|
1024
|
+
},
|
|
1025
|
+
resource: {
|
|
1026
|
+
type: 'configuration',
|
|
1027
|
+
description: 'Encryption key rotation'
|
|
1028
|
+
},
|
|
1029
|
+
details: {
|
|
1030
|
+
previousValues: { keyId: currentKey ? this.activeKeyId : 'none' },
|
|
1031
|
+
newValues: { keyId: newKeyId }
|
|
1032
|
+
},
|
|
1033
|
+
security: {
|
|
1034
|
+
authMethod: 'certificate',
|
|
1035
|
+
encryptionUsed: true,
|
|
1036
|
+
integrityVerified: true,
|
|
1037
|
+
accessLevel: 'elevated'
|
|
1038
|
+
}
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
return newKeyId;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1045
|
+
// DATA MASKING (Minimum Necessary)
|
|
1046
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* Mask PHI according to minimum necessary standard
|
|
1050
|
+
*/
|
|
1051
|
+
maskPHI(data: Record<string, any>, accessLevel: 'full' | 'clinical' | 'billing' | 'research' | 'minimal'): Record<string, any> {
|
|
1052
|
+
const masked = { ...data };
|
|
1053
|
+
|
|
1054
|
+
// Determine masking rules based on access level
|
|
1055
|
+
const maskingRules = this.getMaskingRulesForLevel(accessLevel);
|
|
1056
|
+
|
|
1057
|
+
// Apply masking
|
|
1058
|
+
if (masked.ssn && maskingRules.ssnPattern !== 'full') {
|
|
1059
|
+
masked.ssn = this.maskSSN(masked.ssn, maskingRules.ssnPattern);
|
|
1060
|
+
}
|
|
1061
|
+
if (masked.dob && maskingRules.dobPattern !== 'full') {
|
|
1062
|
+
masked.dob = this.maskDOB(masked.dob, maskingRules.dobPattern);
|
|
1063
|
+
}
|
|
1064
|
+
if (masked.phone && maskingRules.phonePattern !== 'full') {
|
|
1065
|
+
masked.phone = this.maskPhone(masked.phone, maskingRules.phonePattern);
|
|
1066
|
+
}
|
|
1067
|
+
if (masked.address && maskingRules.addressPattern !== 'full') {
|
|
1068
|
+
masked.address = this.maskAddress(masked.address, maskingRules.addressPattern);
|
|
1069
|
+
}
|
|
1070
|
+
if (masked.mrn && maskingRules.mrnPattern !== 'full') {
|
|
1071
|
+
masked.mrn = this.maskMRN(masked.mrn, maskingRules.mrnPattern);
|
|
1072
|
+
}
|
|
1073
|
+
if (masked.genomics && maskingRules.genomicPattern !== 'full') {
|
|
1074
|
+
masked.genomics = this.maskGenomics(masked.genomics, maskingRules.genomicPattern);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
return masked;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
private getMaskingRulesForLevel(level: string): DataMaskingConfig {
|
|
1081
|
+
switch (level) {
|
|
1082
|
+
case 'full':
|
|
1083
|
+
return {
|
|
1084
|
+
ssnPattern: 'full',
|
|
1085
|
+
dobPattern: 'full',
|
|
1086
|
+
phonePattern: 'full',
|
|
1087
|
+
addressPattern: 'full',
|
|
1088
|
+
mrnPattern: 'full',
|
|
1089
|
+
genomicPattern: 'full'
|
|
1090
|
+
};
|
|
1091
|
+
case 'clinical':
|
|
1092
|
+
return {
|
|
1093
|
+
ssnPattern: 'last4',
|
|
1094
|
+
dobPattern: 'full',
|
|
1095
|
+
phonePattern: 'full',
|
|
1096
|
+
addressPattern: 'full',
|
|
1097
|
+
mrnPattern: 'full',
|
|
1098
|
+
genomicPattern: 'full'
|
|
1099
|
+
};
|
|
1100
|
+
case 'billing':
|
|
1101
|
+
return {
|
|
1102
|
+
ssnPattern: 'last4',
|
|
1103
|
+
dobPattern: 'full',
|
|
1104
|
+
phonePattern: 'last4',
|
|
1105
|
+
addressPattern: 'full',
|
|
1106
|
+
mrnPattern: 'full',
|
|
1107
|
+
genomicPattern: 'hidden'
|
|
1108
|
+
};
|
|
1109
|
+
case 'research':
|
|
1110
|
+
return {
|
|
1111
|
+
ssnPattern: 'hidden',
|
|
1112
|
+
dobPattern: 'year-only',
|
|
1113
|
+
phonePattern: 'hidden',
|
|
1114
|
+
addressPattern: 'zip-only',
|
|
1115
|
+
mrnPattern: 'hidden',
|
|
1116
|
+
genomicPattern: 'summary'
|
|
1117
|
+
};
|
|
1118
|
+
case 'minimal':
|
|
1119
|
+
default:
|
|
1120
|
+
return {
|
|
1121
|
+
ssnPattern: 'hidden',
|
|
1122
|
+
dobPattern: 'age-only',
|
|
1123
|
+
phonePattern: 'hidden',
|
|
1124
|
+
addressPattern: 'hidden',
|
|
1125
|
+
mrnPattern: 'hidden',
|
|
1126
|
+
genomicPattern: 'hidden'
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
private maskSSN(ssn: string, pattern: DataMaskingConfig['ssnPattern']): string {
|
|
1132
|
+
const cleaned = ssn.replace(/\D/g, '');
|
|
1133
|
+
switch (pattern) {
|
|
1134
|
+
case 'last4':
|
|
1135
|
+
return `***-**-${cleaned.slice(-4)}`;
|
|
1136
|
+
case 'hidden':
|
|
1137
|
+
return '***-**-****';
|
|
1138
|
+
default:
|
|
1139
|
+
return ssn;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
private maskDOB(dob: string | Date, pattern: DataMaskingConfig['dobPattern']): string {
|
|
1144
|
+
const date = typeof dob === 'string' ? new Date(dob) : dob;
|
|
1145
|
+
switch (pattern) {
|
|
1146
|
+
case 'year-only':
|
|
1147
|
+
return date.getFullYear().toString();
|
|
1148
|
+
case 'age-only':
|
|
1149
|
+
const age = Math.floor((Date.now() - date.getTime()) / (365.25 * 24 * 60 * 60 * 1000));
|
|
1150
|
+
return `Age: ${age}`;
|
|
1151
|
+
case 'hidden':
|
|
1152
|
+
return '[REDACTED]';
|
|
1153
|
+
default:
|
|
1154
|
+
return typeof dob === 'string' ? dob : date.toISOString().split('T')[0];
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
private maskPhone(phone: string, pattern: DataMaskingConfig['phonePattern']): string {
|
|
1159
|
+
const cleaned = phone.replace(/\D/g, '');
|
|
1160
|
+
switch (pattern) {
|
|
1161
|
+
case 'last4':
|
|
1162
|
+
return `(***) ***-${cleaned.slice(-4)}`;
|
|
1163
|
+
case 'hidden':
|
|
1164
|
+
return '(***) ***-****';
|
|
1165
|
+
default:
|
|
1166
|
+
return phone;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
private maskAddress(address: any, pattern: DataMaskingConfig['addressPattern']): any {
|
|
1171
|
+
if (typeof address === 'string') {
|
|
1172
|
+
switch (pattern) {
|
|
1173
|
+
case 'city-state':
|
|
1174
|
+
return '[City, State]';
|
|
1175
|
+
case 'zip-only':
|
|
1176
|
+
const zipMatch = address.match(/\d{5}(-\d{4})?/);
|
|
1177
|
+
return zipMatch ? zipMatch[0] : '[ZIP]';
|
|
1178
|
+
case 'hidden':
|
|
1179
|
+
return '[REDACTED]';
|
|
1180
|
+
default:
|
|
1181
|
+
return address;
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// Object address
|
|
1186
|
+
switch (pattern) {
|
|
1187
|
+
case 'city-state':
|
|
1188
|
+
return { city: address.city, state: address.state };
|
|
1189
|
+
case 'zip-only':
|
|
1190
|
+
return { zip: address.zip || address.postalCode };
|
|
1191
|
+
case 'hidden':
|
|
1192
|
+
return '[REDACTED]';
|
|
1193
|
+
default:
|
|
1194
|
+
return address;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
private maskMRN(mrn: string, pattern: DataMaskingConfig['mrnPattern']): string {
|
|
1199
|
+
switch (pattern) {
|
|
1200
|
+
case 'last4':
|
|
1201
|
+
return `****${mrn.slice(-4)}`;
|
|
1202
|
+
case 'hidden':
|
|
1203
|
+
return '[REDACTED]';
|
|
1204
|
+
default:
|
|
1205
|
+
return mrn;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
private maskGenomics(genomics: any, pattern: DataMaskingConfig['genomicPattern']): any {
|
|
1210
|
+
switch (pattern) {
|
|
1211
|
+
case 'summary':
|
|
1212
|
+
if (Array.isArray(genomics)) {
|
|
1213
|
+
return { count: genomics.length, summary: 'Genomic data available' };
|
|
1214
|
+
}
|
|
1215
|
+
return { summary: 'Genomic data available' };
|
|
1216
|
+
case 'hidden':
|
|
1217
|
+
return '[GENOMIC DATA REDACTED]';
|
|
1218
|
+
default:
|
|
1219
|
+
return genomics;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1224
|
+
// HELPER METHODS
|
|
1225
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1226
|
+
|
|
1227
|
+
private generateAuditId(): string {
|
|
1228
|
+
return `AUD-${Date.now()}-${randomBytes(4).toString('hex')}`;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
private generateConsentId(): string {
|
|
1232
|
+
return `CON-${Date.now()}-${randomBytes(4).toString('hex')}`;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
private generatePolicyId(): string {
|
|
1236
|
+
return `POL-${Date.now()}-${randomBytes(4).toString('hex')}`;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
private generateKeyId(): string {
|
|
1240
|
+
return `KEY-${Date.now()}-${randomBytes(4).toString('hex')}`;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
private async initializeEncryptionKey(keyId?: string): Promise<void> {
|
|
1244
|
+
const id = keyId || this.activeKeyId;
|
|
1245
|
+
|
|
1246
|
+
// In production, this would use a proper key management system (KMS)
|
|
1247
|
+
// For now, derive from a master secret
|
|
1248
|
+
const masterSecret = process.env.HIPAA_MASTER_KEY || 'DEFAULT_DEV_KEY_CHANGE_IN_PRODUCTION';
|
|
1249
|
+
|
|
1250
|
+
return new Promise((resolve, reject) => {
|
|
1251
|
+
scrypt(masterSecret, id, 32, (err, derivedKey) => {
|
|
1252
|
+
if (err) {
|
|
1253
|
+
reject(err);
|
|
1254
|
+
} else {
|
|
1255
|
+
this.encryptionKeys.set(id, {
|
|
1256
|
+
key: derivedKey,
|
|
1257
|
+
createdAt: new Date(),
|
|
1258
|
+
active: true
|
|
1259
|
+
});
|
|
1260
|
+
resolve();
|
|
1261
|
+
}
|
|
1262
|
+
});
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
private calculateEntryHash(entry: Omit<AuditLogEntry, 'entryHash'>): string {
|
|
1267
|
+
const content = JSON.stringify({
|
|
1268
|
+
...entry,
|
|
1269
|
+
timestamp: entry.timestamp.toISOString()
|
|
1270
|
+
});
|
|
1271
|
+
return createHash('sha256').update(content).digest('hex');
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
private async detectSuspiciousActivity(entry: AuditLogEntry): Promise<void> {
|
|
1275
|
+
// Check for patterns that might indicate security issues
|
|
1276
|
+
const suspiciousPatterns: { pattern: () => boolean; alert: string }[] = [
|
|
1277
|
+
{
|
|
1278
|
+
pattern: () => entry.action === 'login-failed',
|
|
1279
|
+
alert: 'Failed login attempt'
|
|
1280
|
+
},
|
|
1281
|
+
{
|
|
1282
|
+
pattern: () => entry.security.accessLevel === 'emergency',
|
|
1283
|
+
alert: 'Emergency access used'
|
|
1284
|
+
},
|
|
1285
|
+
{
|
|
1286
|
+
pattern: () => {
|
|
1287
|
+
// Check for after-hours access
|
|
1288
|
+
const hour = entry.timestamp.getHours();
|
|
1289
|
+
return (hour < 6 || hour > 22) && entry.eventType === 'access';
|
|
1290
|
+
},
|
|
1291
|
+
alert: 'After-hours PHI access'
|
|
1292
|
+
},
|
|
1293
|
+
{
|
|
1294
|
+
pattern: () => {
|
|
1295
|
+
// Check for excessive access
|
|
1296
|
+
const recentAccess = this.auditStore.filter(e =>
|
|
1297
|
+
e.actor.userId === entry.actor.userId &&
|
|
1298
|
+
e.eventType === 'access' &&
|
|
1299
|
+
e.timestamp.getTime() > Date.now() - 60000 // Last minute
|
|
1300
|
+
);
|
|
1301
|
+
return recentAccess.length > 50;
|
|
1302
|
+
},
|
|
1303
|
+
alert: 'Excessive PHI access rate detected'
|
|
1304
|
+
}
|
|
1305
|
+
];
|
|
1306
|
+
|
|
1307
|
+
for (const { pattern, alert } of suspiciousPatterns) {
|
|
1308
|
+
if (pattern()) {
|
|
1309
|
+
this.emit('security-alert', {
|
|
1310
|
+
timestamp: new Date(),
|
|
1311
|
+
alert,
|
|
1312
|
+
auditEntryId: entry.id,
|
|
1313
|
+
actor: entry.actor,
|
|
1314
|
+
severity: 'warning'
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* Export audit logs for compliance reporting
|
|
1322
|
+
*/
|
|
1323
|
+
exportAuditLogs(format: 'json' | 'csv', params?: {
|
|
1324
|
+
startDate?: Date;
|
|
1325
|
+
endDate?: Date;
|
|
1326
|
+
patientId?: string;
|
|
1327
|
+
}): string {
|
|
1328
|
+
const { entries } = this.queryAuditLogs({
|
|
1329
|
+
...params,
|
|
1330
|
+
limit: 100000
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
if (format === 'json') {
|
|
1334
|
+
return JSON.stringify(entries, null, 2);
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// CSV format
|
|
1338
|
+
const headers = [
|
|
1339
|
+
'ID', 'Timestamp', 'Event Type', 'Action', 'Outcome',
|
|
1340
|
+
'User ID', 'User Role', 'IP Address',
|
|
1341
|
+
'Resource Type', 'Patient ID', 'Access Level'
|
|
1342
|
+
];
|
|
1343
|
+
|
|
1344
|
+
const rows = entries.map(e => [
|
|
1345
|
+
e.id,
|
|
1346
|
+
e.timestamp.toISOString(),
|
|
1347
|
+
e.eventType,
|
|
1348
|
+
e.action,
|
|
1349
|
+
e.outcome,
|
|
1350
|
+
e.actor.userId,
|
|
1351
|
+
e.actor.role,
|
|
1352
|
+
e.actor.ipAddress,
|
|
1353
|
+
e.resource.type,
|
|
1354
|
+
e.resource.patientId || '',
|
|
1355
|
+
e.security.accessLevel
|
|
1356
|
+
]);
|
|
1357
|
+
|
|
1358
|
+
return [
|
|
1359
|
+
headers.join(','),
|
|
1360
|
+
...rows.map(r => r.map(v => `"${v}"`).join(','))
|
|
1361
|
+
].join('\n');
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
export default HIPAAComplianceService;
|