@aurabx/jmix-js 0.1.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/.ai/security.md +184 -0
- package/.eslintrc.cjs +23 -0
- package/.idea/jmix-ts.iml +13 -0
- package/.idea/modules.xml +8 -0
- package/.idea/vcs.xml +6 -0
- package/.prettierrc +9 -0
- package/README.md +335 -0
- package/WARP.md +143 -0
- package/dist/JmixBuilder.d.ts +82 -0
- package/dist/JmixBuilder.d.ts.map +1 -0
- package/dist/JmixBuilder.js +353 -0
- package/dist/JmixBuilder.js.map +1 -0
- package/dist/crypto/PayloadDecryptor.d.ts +12 -0
- package/dist/crypto/PayloadDecryptor.d.ts.map +1 -0
- package/dist/crypto/PayloadDecryptor.js +39 -0
- package/dist/crypto/PayloadDecryptor.js.map +1 -0
- package/dist/crypto/PayloadEncryptor.d.ts +21 -0
- package/dist/crypto/PayloadEncryptor.d.ts.map +1 -0
- package/dist/crypto/PayloadEncryptor.js +79 -0
- package/dist/crypto/PayloadEncryptor.js.map +1 -0
- package/dist/demo-decrypt-existing.js +27 -0
- package/dist/demo-decrypt.js +39 -0
- package/dist/demo-no-validation.js +95 -0
- package/dist/demo-package-encrypted.js +34 -0
- package/dist/demo-package.js +28 -0
- package/dist/demo-verify-hash.js +30 -0
- package/dist/demo.js +73 -0
- package/dist/dicom/DicomProcessor.d.ts +40 -0
- package/dist/dicom/DicomProcessor.d.ts.map +1 -0
- package/dist/dicom/DicomProcessor.js +231 -0
- package/dist/dicom/DicomProcessor.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/types/index.d.ts +165 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +31 -0
- package/dist/types/index.js.map +1 -0
- package/dist/validation/SchemaValidator.d.ts +25 -0
- package/dist/validation/SchemaValidator.d.ts.map +1 -0
- package/dist/validation/SchemaValidator.js +125 -0
- package/dist/validation/SchemaValidator.js.map +1 -0
- package/jest.config.json +20 -0
- package/package.json +74 -0
- package/samples/sample_config.json +52 -0
- package/samples/study_1/series_1/CT.1.1.dcm +0 -0
- package/samples/study_1/series_1/CT.1.2.dcm +0 -0
- package/samples/study_1/series_1/CT.1.3.dcm +0 -0
- package/samples/study_1/series_1/CT.1.4.dcm +0 -0
- package/samples/study_1/series_1/CT.1.5.dcm +0 -0
- package/samples/study_1/series_2/CT.2.1.dcm +0 -0
- package/samples/study_1/series_2/CT.2.2.dcm +0 -0
- package/samples/study_1/series_2/CT.2.3.dcm +0 -0
- package/samples/study_1/series_2/CT.2.4.dcm +0 -0
- package/samples/study_1/series_2/CT.2.5.dcm +0 -0
- package/samples/study_1/series_3/CT.3.1.dcm +0 -0
- package/samples/study_1/series_3/CT.3.2.dcm +0 -0
- package/samples/study_1/series_3/CT.3.3.dcm +0 -0
- package/samples/study_1/series_3/CT.3.4.dcm +0 -0
- package/samples/study_1/series_3/CT.3.5.dcm +0 -0
- package/src/JmixBuilder.ts +572 -0
- package/src/crypto/PayloadDecryptor.ts +45 -0
- package/src/crypto/PayloadEncryptor.ts +97 -0
- package/src/dicom/DicomProcessor.ts +262 -0
- package/src/index.ts +30 -0
- package/src/types/daikon.d.ts +1 -0
- package/src/types/index.ts +201 -0
- package/src/validation/SchemaValidator.ts +156 -0
- package/test-encrypt.js +10 -0
- package/tests/JmixBuilder.test.ts +170 -0
- package/tests/SchemaValidator.test.ts +196 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { randomUUID, createHash } from 'crypto';
|
|
4
|
+
import {
|
|
5
|
+
SchemaValidator,
|
|
6
|
+
SchemaValidatorOptions,
|
|
7
|
+
} from './validation/SchemaValidator.js';
|
|
8
|
+
import { DicomProcessor } from './dicom/DicomProcessor.js';
|
|
9
|
+
import { PayloadEncryptor } from './crypto/PayloadEncryptor.js';
|
|
10
|
+
import tar from 'tar';
|
|
11
|
+
import { PayloadDecryptor } from './crypto/PayloadDecryptor.js';
|
|
12
|
+
import {
|
|
13
|
+
JmixEnvelope,
|
|
14
|
+
Config,
|
|
15
|
+
Manifest,
|
|
16
|
+
Metadata,
|
|
17
|
+
Audit,
|
|
18
|
+
AuditEntry,
|
|
19
|
+
JmixError,
|
|
20
|
+
} from './types/index.js';
|
|
21
|
+
|
|
22
|
+
export interface JmixBuilderOptions {
|
|
23
|
+
schemaValidatorOptions?: SchemaValidatorOptions;
|
|
24
|
+
outputPath?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class JmixBuilder {
|
|
28
|
+
private validator: SchemaValidator;
|
|
29
|
+
private dicomProcessor: DicomProcessor;
|
|
30
|
+
private outputPath: string;
|
|
31
|
+
|
|
32
|
+
constructor(options: JmixBuilderOptions = {}) {
|
|
33
|
+
this.validator = new SchemaValidator(options.schemaValidatorOptions);
|
|
34
|
+
this.dicomProcessor = new DicomProcessor();
|
|
35
|
+
this.outputPath = options.outputPath || './tmp';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build a JMIX envelope from DICOM files and configuration
|
|
40
|
+
*/
|
|
41
|
+
async buildFromDicom(
|
|
42
|
+
dicomPath: string,
|
|
43
|
+
config: Config
|
|
44
|
+
): Promise<JmixEnvelope> {
|
|
45
|
+
try {
|
|
46
|
+
// Generate transmission ID and timestamp
|
|
47
|
+
const transmissionId = randomUUID();
|
|
48
|
+
const timestamp = new Date().toISOString();
|
|
49
|
+
|
|
50
|
+
// Extract DICOM metadata
|
|
51
|
+
const dicomMetadata = await this.dicomProcessor.processDicomFolder(
|
|
52
|
+
dicomPath,
|
|
53
|
+
config
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Build envelope components
|
|
57
|
+
const manifest = this.buildManifest(config, timestamp);
|
|
58
|
+
const metadata = this.buildMetadata(config, dicomMetadata);
|
|
59
|
+
const audit = this.buildAudit(
|
|
60
|
+
transmissionId,
|
|
61
|
+
timestamp,
|
|
62
|
+
config
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const envelope: JmixEnvelope = {
|
|
66
|
+
manifest,
|
|
67
|
+
metadata,
|
|
68
|
+
audit,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Validate the envelope components
|
|
72
|
+
await this.validator.validateEnvelope({
|
|
73
|
+
manifest,
|
|
74
|
+
metadata,
|
|
75
|
+
audit,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return envelope;
|
|
79
|
+
} catch (error) {
|
|
80
|
+
throw new JmixError(
|
|
81
|
+
`Failed to build JMIX envelope from ${dicomPath}`,
|
|
82
|
+
error instanceof Error ? error : new Error(String(error))
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Save envelope to JSON files
|
|
89
|
+
*/
|
|
90
|
+
async saveToFiles(
|
|
91
|
+
envelope: JmixEnvelope,
|
|
92
|
+
outputPath?: string
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
const finalOutputPath = outputPath || this.outputPath;
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
// Ensure output directory exists
|
|
98
|
+
await fs.mkdir(finalOutputPath, { recursive: true });
|
|
99
|
+
|
|
100
|
+
// Write manifest.json
|
|
101
|
+
await fs.writeFile(
|
|
102
|
+
path.join(finalOutputPath, 'manifest.json'),
|
|
103
|
+
JSON.stringify(envelope.manifest, null, 2),
|
|
104
|
+
'utf-8'
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// Write metadata.json
|
|
108
|
+
await fs.writeFile(
|
|
109
|
+
path.join(finalOutputPath, 'metadata.json'),
|
|
110
|
+
JSON.stringify(envelope.metadata, null, 2),
|
|
111
|
+
'utf-8'
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// Write audit.json (audit trail)
|
|
115
|
+
await fs.writeFile(
|
|
116
|
+
path.join(finalOutputPath, 'audit.json'),
|
|
117
|
+
JSON.stringify(envelope.audit, null, 2),
|
|
118
|
+
'utf-8'
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Write files.json if present
|
|
122
|
+
if (envelope.files) {
|
|
123
|
+
await fs.writeFile(
|
|
124
|
+
path.join(finalOutputPath, 'files.json'),
|
|
125
|
+
JSON.stringify(envelope.files, null, 2),
|
|
126
|
+
'utf-8'
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
} catch (error) {
|
|
130
|
+
throw new JmixError(
|
|
131
|
+
`Failed to save envelope to ${finalOutputPath}`,
|
|
132
|
+
error instanceof Error ? error : new Error(String(error))
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Build manifest component
|
|
139
|
+
*/
|
|
140
|
+
private buildManifest(config: Config, timestamp: string): Manifest {
|
|
141
|
+
const manifest: Manifest = {
|
|
142
|
+
jmix_version: config.version,
|
|
143
|
+
envelope_id: randomUUID(),
|
|
144
|
+
created_at: timestamp,
|
|
145
|
+
sender: config.sender,
|
|
146
|
+
receivers: config.receivers,
|
|
147
|
+
patient: config.patient,
|
|
148
|
+
security: config.security,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Add optional fields
|
|
152
|
+
if (config.requester) {
|
|
153
|
+
manifest.requester = config.requester;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (config.consent) {
|
|
157
|
+
manifest.consent = config.consent;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (config.custom_tags) {
|
|
161
|
+
manifest.custom_tags = config.custom_tags;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (config.report) {
|
|
165
|
+
manifest.report = config.report;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (config.deid_keys) {
|
|
169
|
+
manifest.deid_keys = config.deid_keys;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return manifest;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Build metadata component
|
|
177
|
+
*/
|
|
178
|
+
private buildMetadata(config: Config, dicomMetadata: any): Metadata {
|
|
179
|
+
return {
|
|
180
|
+
patient: config.patient,
|
|
181
|
+
study: {
|
|
182
|
+
description: dicomMetadata.study_description,
|
|
183
|
+
uid: dicomMetadata.study_uid,
|
|
184
|
+
date: dicomMetadata.study_date,
|
|
185
|
+
},
|
|
186
|
+
dicom: dicomMetadata,
|
|
187
|
+
custom_metadata: {},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Build audit component (audit trail)
|
|
193
|
+
*/
|
|
194
|
+
private buildAudit(
|
|
195
|
+
transmissionId: string,
|
|
196
|
+
timestamp: string,
|
|
197
|
+
config: Config
|
|
198
|
+
): Audit {
|
|
199
|
+
const firstReceiver = config.receivers && config.receivers[0];
|
|
200
|
+
|
|
201
|
+
const entry: AuditEntry = {
|
|
202
|
+
event: 'envelope_created',
|
|
203
|
+
timestamp,
|
|
204
|
+
by: { id: config.sender.id, name: config.sender.name },
|
|
205
|
+
to: firstReceiver ? { id: firstReceiver.id, name: firstReceiver.name } : undefined,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const audit: Audit = {
|
|
209
|
+
audit: [entry],
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
return audit;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Load configuration from JSON file
|
|
217
|
+
*/
|
|
218
|
+
static async loadConfig(configPath: string): Promise<Config> {
|
|
219
|
+
try {
|
|
220
|
+
const configContent = await fs.readFile(configPath, 'utf-8');
|
|
221
|
+
return JSON.parse(configContent) as Config;
|
|
222
|
+
} catch (error) {
|
|
223
|
+
throw new JmixError(
|
|
224
|
+
`Failed to load configuration from ${configPath}`,
|
|
225
|
+
error instanceof Error ? error : new Error(String(error))
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Get the schema validator instance
|
|
233
|
+
*/
|
|
234
|
+
getValidator(): SchemaValidator {
|
|
235
|
+
return this.validator;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Get the DICOM processor instance
|
|
240
|
+
*/
|
|
241
|
+
getDicomProcessor(): DicomProcessor {
|
|
242
|
+
return this.dicomProcessor;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Package an ENCRYPTED JMIX envelope directory:
|
|
247
|
+
* - Builds plaintext payload/ first (metadata.json + dicom/)
|
|
248
|
+
* - Computes payload_hash over payload/
|
|
249
|
+
* - Tars payload/, encrypts to payload.encrypted with AES-256-GCM via X25519+HKDF
|
|
250
|
+
* - Removes plaintext payload/ directory
|
|
251
|
+
* Layout:
|
|
252
|
+
* <outputRoot>/<envelope_id>.JMIX/
|
|
253
|
+
* manifest.json (includes security.encryption & payload_hash)
|
|
254
|
+
* audit.json
|
|
255
|
+
* payload.encrypted
|
|
256
|
+
*/
|
|
257
|
+
async packageEncryptedToDirectory(
|
|
258
|
+
dicomPath: string,
|
|
259
|
+
config: Config,
|
|
260
|
+
outputRoot: string,
|
|
261
|
+
recipientPublicKeyBase64: string
|
|
262
|
+
): Promise<string> {
|
|
263
|
+
// Build the envelope first
|
|
264
|
+
const envelope = await this.buildFromDicom(dicomPath, config);
|
|
265
|
+
|
|
266
|
+
const packageRoot = path.resolve(outputRoot, `${envelope.manifest.envelope_id}.JMIX`);
|
|
267
|
+
const payloadDir = path.join(packageRoot, 'payload');
|
|
268
|
+
const dicomOutDir = path.join(payloadDir, 'dicom');
|
|
269
|
+
|
|
270
|
+
// Create directory structure and copy DICOM
|
|
271
|
+
await fs.mkdir(dicomOutDir, { recursive: true });
|
|
272
|
+
|
|
273
|
+
const allInputFiles = await this.walkFiles(dicomPath);
|
|
274
|
+
for (const absFile of allInputFiles) {
|
|
275
|
+
if (!(await this.isLikelyDicom(absFile))) continue;
|
|
276
|
+
const rel = path.relative(dicomPath, absFile);
|
|
277
|
+
const dest = path.join(dicomOutDir, rel);
|
|
278
|
+
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
279
|
+
await fs.copyFile(absFile, dest);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Write payload/metadata.json
|
|
283
|
+
await fs.mkdir(payloadDir, { recursive: true });
|
|
284
|
+
await fs.writeFile(
|
|
285
|
+
path.join(payloadDir, 'metadata.json'),
|
|
286
|
+
JSON.stringify(envelope.metadata, null, 2),
|
|
287
|
+
'utf-8'
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
// Compute payload hash over plaintext payload
|
|
291
|
+
const payloadHash = await this.computePayloadHash(payloadDir);
|
|
292
|
+
|
|
293
|
+
// Tar the payload directory to a temporary tar file under the package root
|
|
294
|
+
const tmpTarPath = path.join(packageRoot, 'payload.tmp.tar');
|
|
295
|
+
await tar.c({ cwd: packageRoot, file: tmpTarPath, portable: true, gzip: false }, ['payload']);
|
|
296
|
+
|
|
297
|
+
// Read tar and encrypt
|
|
298
|
+
const tarBuf = await fs.readFile(tmpTarPath);
|
|
299
|
+
const { ciphertext, result } = PayloadEncryptor.encryptTar(tarBuf, recipientPublicKeyBase64);
|
|
300
|
+
const encryptedPath = path.join(packageRoot, 'payload.encrypted');
|
|
301
|
+
await fs.writeFile(encryptedPath, ciphertext);
|
|
302
|
+
|
|
303
|
+
// Cleanup plaintext tar and payload directory
|
|
304
|
+
await fs.rm(tmpTarPath, { force: true });
|
|
305
|
+
await fs.rm(payloadDir, { recursive: true, force: true });
|
|
306
|
+
|
|
307
|
+
// Update manifest security
|
|
308
|
+
envelope.manifest.security = {
|
|
309
|
+
...envelope.manifest.security,
|
|
310
|
+
payload_hash: payloadHash,
|
|
311
|
+
encryption: {
|
|
312
|
+
algorithm: 'AES-256-GCM',
|
|
313
|
+
ephemeral_public_key: result.ephemeral_public_key,
|
|
314
|
+
iv: result.iv,
|
|
315
|
+
auth_tag: result.auth_tag,
|
|
316
|
+
},
|
|
317
|
+
} as any;
|
|
318
|
+
|
|
319
|
+
// Write manifest.json and audit.json
|
|
320
|
+
await fs.mkdir(packageRoot, { recursive: true });
|
|
321
|
+
await fs.writeFile(
|
|
322
|
+
path.join(packageRoot, 'manifest.json'),
|
|
323
|
+
JSON.stringify(envelope.manifest, null, 2),
|
|
324
|
+
'utf-8'
|
|
325
|
+
);
|
|
326
|
+
await fs.writeFile(
|
|
327
|
+
path.join(packageRoot, 'audit.json'),
|
|
328
|
+
JSON.stringify(envelope.audit, null, 2),
|
|
329
|
+
'utf-8'
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
return packageRoot;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Package a JMIX envelope to a directory structure that includes original DICOM files.
|
|
337
|
+
* Layout:
|
|
338
|
+
* <outputRoot>/<envelope_id>.JMIX/
|
|
339
|
+
* manifest.json
|
|
340
|
+
* audit.json
|
|
341
|
+
* payload/
|
|
342
|
+
* metadata.json
|
|
343
|
+
* dicom/ ... (copied from dicomPath, preserving structure)
|
|
344
|
+
* files/ (optional in future)
|
|
345
|
+
* files.json (optional in future)
|
|
346
|
+
*/
|
|
347
|
+
async packageToDirectory(
|
|
348
|
+
dicomPath: string,
|
|
349
|
+
config: Config,
|
|
350
|
+
outputRoot: string
|
|
351
|
+
): Promise<string> {
|
|
352
|
+
// Build the envelope first
|
|
353
|
+
const envelope = await this.buildFromDicom(dicomPath, config);
|
|
354
|
+
|
|
355
|
+
const packageRoot = path.resolve(outputRoot, `${envelope.manifest.envelope_id}.JMIX`);
|
|
356
|
+
const payloadDir = path.join(packageRoot, 'payload');
|
|
357
|
+
const dicomOutDir = path.join(payloadDir, 'dicom');
|
|
358
|
+
|
|
359
|
+
// Create directory structure
|
|
360
|
+
await fs.mkdir(dicomOutDir, { recursive: true });
|
|
361
|
+
|
|
362
|
+
// Copy DICOM files preserving relative structure
|
|
363
|
+
const allInputFiles = await this.walkFiles(dicomPath);
|
|
364
|
+
for (const absFile of allInputFiles) {
|
|
365
|
+
if (!(await this.isLikelyDicom(absFile))) continue;
|
|
366
|
+
const rel = path.relative(dicomPath, absFile);
|
|
367
|
+
const dest = path.join(dicomOutDir, rel);
|
|
368
|
+
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
369
|
+
await fs.copyFile(absFile, dest);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Write payload/metadata.json
|
|
373
|
+
await fs.mkdir(payloadDir, { recursive: true });
|
|
374
|
+
await fs.writeFile(
|
|
375
|
+
path.join(payloadDir, 'metadata.json'),
|
|
376
|
+
JSON.stringify(envelope.metadata, null, 2),
|
|
377
|
+
'utf-8'
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
// Compute payload hash over all files under payload/
|
|
381
|
+
const payloadHash = await this.computePayloadHash(payloadDir);
|
|
382
|
+
envelope.manifest.security = {
|
|
383
|
+
...envelope.manifest.security,
|
|
384
|
+
payload_hash: payloadHash,
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
// Write manifest.json and audit.json at package root
|
|
388
|
+
await fs.writeFile(
|
|
389
|
+
path.join(packageRoot, 'manifest.json'),
|
|
390
|
+
JSON.stringify(envelope.manifest, null, 2),
|
|
391
|
+
'utf-8'
|
|
392
|
+
);
|
|
393
|
+
await fs.writeFile(
|
|
394
|
+
path.join(packageRoot, 'audit.json'),
|
|
395
|
+
JSON.stringify(envelope.audit, null, 2),
|
|
396
|
+
'utf-8'
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
return packageRoot;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Recursively list all files under a directory (files only)
|
|
403
|
+
private async walkFiles(root: string): Promise<string[]> {
|
|
404
|
+
const out: string[] = [];
|
|
405
|
+
const entries = await fs.readdir(root, { withFileTypes: true });
|
|
406
|
+
for (const entry of entries) {
|
|
407
|
+
const full = path.join(root, entry.name);
|
|
408
|
+
if (entry.isDirectory()) {
|
|
409
|
+
out.push(...(await this.walkFiles(full)));
|
|
410
|
+
} else if (entry.isFile()) {
|
|
411
|
+
out.push(full);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return out;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Basic DICOM detection: extension or DICM magic at offset 128
|
|
418
|
+
private async isLikelyDicom(filePath: string): Promise<boolean> {
|
|
419
|
+
try {
|
|
420
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
421
|
+
if (ext === '.dcm' || ext === '.dicom') return true;
|
|
422
|
+
|
|
423
|
+
const fh = await fs.open(filePath, 'r');
|
|
424
|
+
try {
|
|
425
|
+
const buf = Buffer.alloc(4);
|
|
426
|
+
await fh.read(buf, 0, 4, 128);
|
|
427
|
+
return buf.toString('ascii') === 'DICM';
|
|
428
|
+
} finally {
|
|
429
|
+
await fh.close();
|
|
430
|
+
}
|
|
431
|
+
} catch {
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Deterministic payload hash: sha256 over ordered (path + newline + bytes) for all files under payload/
|
|
437
|
+
private async computePayloadHash(payloadDir: string): Promise<string> {
|
|
438
|
+
const all = await this.walkFiles(payloadDir);
|
|
439
|
+
const rels = all
|
|
440
|
+
.map((abs) => ({ abs, rel: path.posix.join(...path.relative(payloadDir, abs).split(path.sep)) }))
|
|
441
|
+
.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
|
|
442
|
+
|
|
443
|
+
const hash = createHash('sha256');
|
|
444
|
+
for (const f of rels) {
|
|
445
|
+
hash.update(Buffer.from(f.rel + '\n', 'utf-8'));
|
|
446
|
+
const data = await fs.readFile(f.abs);
|
|
447
|
+
hash.update(data);
|
|
448
|
+
}
|
|
449
|
+
const digestHex = hash.digest('hex');
|
|
450
|
+
return `sha256:${digestHex}`;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Decrypt an encrypted JMIX envelope in place: restores a plaintext payload/ directory.
|
|
455
|
+
* - envelopeDir: path to <id>.JMIX directory
|
|
456
|
+
* - recipientPrivateKeyBase64: base64 Curve25519 private key (32 bytes)
|
|
457
|
+
* Returns the extracted payload directory path.
|
|
458
|
+
*/
|
|
459
|
+
async decryptEnvelope(
|
|
460
|
+
envelopeDir: string,
|
|
461
|
+
recipientPrivateKeyBase64: string
|
|
462
|
+
): Promise<string> {
|
|
463
|
+
const manifestPath = path.join(envelopeDir, 'manifest.json');
|
|
464
|
+
const encryptedPath = path.join(envelopeDir, 'payload.encrypted');
|
|
465
|
+
|
|
466
|
+
// Read manifest and encryption info
|
|
467
|
+
const manifestRaw = await fs.readFile(manifestPath, 'utf-8');
|
|
468
|
+
const manifest = JSON.parse(manifestRaw);
|
|
469
|
+
const enc = manifest?.security?.encryption;
|
|
470
|
+
if (!enc || !enc.ephemeral_public_key || !enc.iv || !enc.auth_tag) {
|
|
471
|
+
throw new Error('Envelope is not encrypted or missing encryption metadata');
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Read ciphertext
|
|
475
|
+
const ciphertext = await fs.readFile(encryptedPath);
|
|
476
|
+
|
|
477
|
+
// Decrypt to tar buffer
|
|
478
|
+
const tarBuf = PayloadDecryptor.decryptToBuffer(
|
|
479
|
+
ciphertext,
|
|
480
|
+
enc.ephemeral_public_key,
|
|
481
|
+
enc.iv,
|
|
482
|
+
enc.auth_tag,
|
|
483
|
+
recipientPrivateKeyBase64
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
// Write tar to temp and extract to payload/
|
|
487
|
+
const tmpTar = path.join(envelopeDir, 'payload.decrypted.tar');
|
|
488
|
+
await fs.writeFile(tmpTar, tarBuf);
|
|
489
|
+
|
|
490
|
+
const payloadDir = path.join(envelopeDir, 'payload');
|
|
491
|
+
await fs.mkdir(payloadDir, { recursive: true });
|
|
492
|
+
await tar.x({ cwd: envelopeDir, file: tmpTar });
|
|
493
|
+
|
|
494
|
+
// Remove temp tar
|
|
495
|
+
await fs.rm(tmpTar, { force: true });
|
|
496
|
+
|
|
497
|
+
// Optional: verify payload hash
|
|
498
|
+
if (manifest?.security?.payload_hash) {
|
|
499
|
+
const computed = await this.computePayloadHash(payloadDir);
|
|
500
|
+
if (computed !== manifest.security.payload_hash) {
|
|
501
|
+
throw new Error(`Payload hash mismatch: expected ${manifest.security.payload_hash} but got ${computed}`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return payloadDir;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Verify the payload hash in manifest.security.payload_hash.
|
|
510
|
+
* - If payload/ exists: compute directly.
|
|
511
|
+
* - If payload.encrypted exists: requires recipientPrivateKeyBase64 to decrypt to a temporary folder.
|
|
512
|
+
* Returns { ok, expected, computed, mode } where mode is 'plaintext' or 'encrypted'.
|
|
513
|
+
*/
|
|
514
|
+
async verifyPayloadHash(
|
|
515
|
+
envelopeDir: string,
|
|
516
|
+
opts: { recipientPrivateKeyBase64?: string; tempDir?: string } = {}
|
|
517
|
+
): Promise<{ ok: boolean; expected?: string; computed?: string; mode: 'plaintext' | 'encrypted' }> {
|
|
518
|
+
const manifestPath = path.join(envelopeDir, 'manifest.json');
|
|
519
|
+
const manifestRaw = await fs.readFile(manifestPath, 'utf-8');
|
|
520
|
+
const manifest = JSON.parse(manifestRaw);
|
|
521
|
+
const expected: string | undefined = manifest?.security?.payload_hash;
|
|
522
|
+
|
|
523
|
+
const payloadDir = path.join(envelopeDir, 'payload');
|
|
524
|
+
const encryptedPath = path.join(envelopeDir, 'payload.encrypted');
|
|
525
|
+
|
|
526
|
+
try {
|
|
527
|
+
const stat = await fs.stat(payloadDir).catch(() => undefined);
|
|
528
|
+
if (stat && stat.isDirectory()) {
|
|
529
|
+
const computed = await this.computePayloadHash(payloadDir);
|
|
530
|
+
return { ok: !!expected && computed === expected, expected, computed, mode: 'plaintext' };
|
|
531
|
+
}
|
|
532
|
+
} catch {}
|
|
533
|
+
|
|
534
|
+
// Encrypted path
|
|
535
|
+
const encStat = await fs.stat(encryptedPath).catch(() => undefined);
|
|
536
|
+
if (encStat && encStat.isFile()) {
|
|
537
|
+
if (!opts.recipientPrivateKeyBase64) {
|
|
538
|
+
return { ok: false, expected, computed: undefined, mode: 'encrypted' };
|
|
539
|
+
}
|
|
540
|
+
// Decrypt to temp payload under ./tmp
|
|
541
|
+
const tmpRoot = path.resolve(opts.tempDir || this.outputPath || './tmp');
|
|
542
|
+
await fs.mkdir(tmpRoot, { recursive: true });
|
|
543
|
+
const tmpWork = path.join(tmpRoot, `verify-${randomUUID()}`);
|
|
544
|
+
await fs.mkdir(tmpWork, { recursive: true });
|
|
545
|
+
|
|
546
|
+
const ciphertext = await fs.readFile(encryptedPath);
|
|
547
|
+
const enc = manifest.security.encryption;
|
|
548
|
+
const tarBuf = PayloadDecryptor.decryptToBuffer(
|
|
549
|
+
ciphertext,
|
|
550
|
+
enc.ephemeral_public_key,
|
|
551
|
+
enc.iv,
|
|
552
|
+
enc.auth_tag,
|
|
553
|
+
opts.recipientPrivateKeyBase64
|
|
554
|
+
);
|
|
555
|
+
const tmpTar = path.join(tmpWork, 'payload.tar');
|
|
556
|
+
await fs.writeFile(tmpTar, tarBuf);
|
|
557
|
+
const tmpPayload = path.join(tmpWork, 'payload');
|
|
558
|
+
await fs.mkdir(tmpPayload, { recursive: true });
|
|
559
|
+
await tar.x({ cwd: tmpWork, file: tmpTar });
|
|
560
|
+
|
|
561
|
+
const computed = await this.computePayloadHash(tmpPayload);
|
|
562
|
+
|
|
563
|
+
// Cleanup
|
|
564
|
+
await fs.rm(tmpWork, { recursive: true, force: true });
|
|
565
|
+
|
|
566
|
+
return { ok: !!expected && computed === expected, expected, computed, mode: 'encrypted' };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// No payload present
|
|
570
|
+
return { ok: false, expected, computed: undefined, mode: 'plaintext' };
|
|
571
|
+
}
|
|
572
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createDecipheriv, hkdfSync } from 'crypto';
|
|
2
|
+
import nacl from 'tweetnacl';
|
|
3
|
+
|
|
4
|
+
export class PayloadDecryptor {
|
|
5
|
+
/**
|
|
6
|
+
* Decrypt AES-256-GCM ciphertext using X25519 + HKDF-SHA256 key derivation.
|
|
7
|
+
* - ephPubB64: base64 sender ephemeral public key (32 bytes)
|
|
8
|
+
* - ivB64: base64 12-byte IV
|
|
9
|
+
* - tagB64: base64 16-byte auth tag
|
|
10
|
+
* - recipientPrivB64: base64 32-byte recipient private key
|
|
11
|
+
* Returns plaintext Buffer (tar data).
|
|
12
|
+
*/
|
|
13
|
+
static decryptToBuffer(
|
|
14
|
+
ciphertext: Buffer,
|
|
15
|
+
ephPubB64: string,
|
|
16
|
+
ivB64: string,
|
|
17
|
+
tagB64: string,
|
|
18
|
+
recipientPrivB64: string,
|
|
19
|
+
): Buffer {
|
|
20
|
+
const ephPub = Buffer.from(ephPubB64, 'base64');
|
|
21
|
+
const iv = Buffer.from(ivB64, 'base64');
|
|
22
|
+
const tag = Buffer.from(tagB64, 'base64');
|
|
23
|
+
const recipPriv = Buffer.from(recipientPrivB64, 'base64');
|
|
24
|
+
|
|
25
|
+
if (ephPub.length !== 32) throw new Error('Invalid ephemeral public key');
|
|
26
|
+
if (recipPriv.length !== 32) throw new Error('Invalid recipient private key');
|
|
27
|
+
if (iv.length !== 12) throw new Error('Invalid IV length (expected 12)');
|
|
28
|
+
if (tag.length !== 16) throw new Error('Invalid auth tag length (expected 16)');
|
|
29
|
+
|
|
30
|
+
// X25519 shared secret
|
|
31
|
+
const shared = nacl.scalarMult(new Uint8Array(recipPriv), new Uint8Array(ephPub));
|
|
32
|
+
|
|
33
|
+
// Derive 32-byte AES key with HKDF-SHA256
|
|
34
|
+
const salt = Buffer.alloc(0);
|
|
35
|
+
const info = Buffer.from('JMIX-Payload-Encryption');
|
|
36
|
+
const hkdfOut = hkdfSync('sha256', Buffer.from(shared), salt, info, 32);
|
|
37
|
+
const key = Buffer.from(hkdfOut);
|
|
38
|
+
|
|
39
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
40
|
+
decipher.setAuthTag(tag);
|
|
41
|
+
const dec1 = decipher.update(ciphertext);
|
|
42
|
+
const dec2 = decipher.final();
|
|
43
|
+
return Buffer.concat([dec1, dec2]);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { randomBytes, createCipheriv, hkdfSync } from 'crypto';
|
|
4
|
+
import nacl from 'tweetnacl';
|
|
5
|
+
import tar from 'tar-stream';
|
|
6
|
+
import { buffer as consumeBuffer } from 'node:stream/consumers';
|
|
7
|
+
|
|
8
|
+
export interface EncryptResult {
|
|
9
|
+
ephemeral_public_key: string; // base64
|
|
10
|
+
iv: string; // base64
|
|
11
|
+
auth_tag: string; // base64
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class PayloadEncryptor {
|
|
15
|
+
/**
|
|
16
|
+
* Create a tar archive (in-memory) from a directory.
|
|
17
|
+
* Paths inside the tar are relative to baseDir.
|
|
18
|
+
*/
|
|
19
|
+
static async tarDirectory(baseDir: string): Promise<Buffer> {
|
|
20
|
+
const pack = tar.pack();
|
|
21
|
+
|
|
22
|
+
async function addDir(dir: string, relPrefix = ''): Promise<void> {
|
|
23
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
const abs = path.join(dir, entry.name);
|
|
26
|
+
const rel = path.posix.join(relPrefix, entry.name);
|
|
27
|
+
if (entry.isDirectory()) {
|
|
28
|
+
await addDir(abs, rel);
|
|
29
|
+
} else if (entry.isFile()) {
|
|
30
|
+
const stat = await fs.stat(abs);
|
|
31
|
+
const content = await fs.readFile(abs);
|
|
32
|
+
await new Promise<void>((resolve, reject) => {
|
|
33
|
+
const header = {
|
|
34
|
+
name: rel,
|
|
35
|
+
size: stat.size,
|
|
36
|
+
mode: stat.mode,
|
|
37
|
+
mtime: stat.mtime,
|
|
38
|
+
type: 'file' as const,
|
|
39
|
+
};
|
|
40
|
+
pack.entry(header, content, (err: any) => (err ? reject(err) : resolve()));
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await addDir(baseDir);
|
|
47
|
+
pack.finalize();
|
|
48
|
+
|
|
49
|
+
// Collect to Buffer using Node's consumers
|
|
50
|
+
const outBuf = await consumeBuffer(pack as any);
|
|
51
|
+
return Buffer.from(outBuf);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Encrypt payload tar with AES-256-GCM using ECDH (X25519) derived key via HKDF-SHA256.
|
|
56
|
+
* recipientPublicKeyBase64 is a 32-byte Curve25519 public key, base64 encoded.
|
|
57
|
+
*/
|
|
58
|
+
static encryptTar(
|
|
59
|
+
tarData: Buffer,
|
|
60
|
+
recipientPublicKeyBase64: string,
|
|
61
|
+
): { ciphertext: Buffer; result: EncryptResult } {
|
|
62
|
+
const recipientPub = Buffer.from(recipientPublicKeyBase64, 'base64');
|
|
63
|
+
if (recipientPub.length !== 32) {
|
|
64
|
+
throw new Error('Invalid recipient public key length: expected 32 bytes base64');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Ephemeral keypair
|
|
68
|
+
const eph = nacl.box.keyPair();
|
|
69
|
+
|
|
70
|
+
// X25519 shared secret
|
|
71
|
+
const shared = nacl.scalarMult(eph.secretKey, new Uint8Array(recipientPub));
|
|
72
|
+
|
|
73
|
+
// Derive 32-byte AES key with HKDF-SHA256
|
|
74
|
+
const salt = Buffer.alloc(0); // empty salt
|
|
75
|
+
const info = Buffer.from('JMIX-Payload-Encryption');
|
|
76
|
+
const hkdfOut = hkdfSync('sha256', Buffer.from(shared), salt, info, 32);
|
|
77
|
+
const key = Buffer.from(hkdfOut);
|
|
78
|
+
|
|
79
|
+
// Encrypt with AES-256-GCM
|
|
80
|
+
const iv = randomBytes(12);
|
|
81
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
82
|
+
const enc1 = cipher.update(tarData);
|
|
83
|
+
const enc2 = cipher.final();
|
|
84
|
+
const auth = cipher.getAuthTag();
|
|
85
|
+
|
|
86
|
+
const ciphertext = Buffer.concat([enc1, enc2]);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
ciphertext,
|
|
90
|
+
result: {
|
|
91
|
+
ephemeral_public_key: Buffer.from(eph.publicKey).toString('base64'),
|
|
92
|
+
iv: iv.toString('base64'),
|
|
93
|
+
auth_tag: auth.toString('base64'),
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|