@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.
Files changed (73) hide show
  1. package/.ai/security.md +184 -0
  2. package/.eslintrc.cjs +23 -0
  3. package/.idea/jmix-ts.iml +13 -0
  4. package/.idea/modules.xml +8 -0
  5. package/.idea/vcs.xml +6 -0
  6. package/.prettierrc +9 -0
  7. package/README.md +335 -0
  8. package/WARP.md +143 -0
  9. package/dist/JmixBuilder.d.ts +82 -0
  10. package/dist/JmixBuilder.d.ts.map +1 -0
  11. package/dist/JmixBuilder.js +353 -0
  12. package/dist/JmixBuilder.js.map +1 -0
  13. package/dist/crypto/PayloadDecryptor.d.ts +12 -0
  14. package/dist/crypto/PayloadDecryptor.d.ts.map +1 -0
  15. package/dist/crypto/PayloadDecryptor.js +39 -0
  16. package/dist/crypto/PayloadDecryptor.js.map +1 -0
  17. package/dist/crypto/PayloadEncryptor.d.ts +21 -0
  18. package/dist/crypto/PayloadEncryptor.d.ts.map +1 -0
  19. package/dist/crypto/PayloadEncryptor.js +79 -0
  20. package/dist/crypto/PayloadEncryptor.js.map +1 -0
  21. package/dist/demo-decrypt-existing.js +27 -0
  22. package/dist/demo-decrypt.js +39 -0
  23. package/dist/demo-no-validation.js +95 -0
  24. package/dist/demo-package-encrypted.js +34 -0
  25. package/dist/demo-package.js +28 -0
  26. package/dist/demo-verify-hash.js +30 -0
  27. package/dist/demo.js +73 -0
  28. package/dist/dicom/DicomProcessor.d.ts +40 -0
  29. package/dist/dicom/DicomProcessor.d.ts.map +1 -0
  30. package/dist/dicom/DicomProcessor.js +231 -0
  31. package/dist/dicom/DicomProcessor.js.map +1 -0
  32. package/dist/index.d.ts +6 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/index.js +8 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/types/index.d.ts +165 -0
  37. package/dist/types/index.d.ts.map +1 -0
  38. package/dist/types/index.js +31 -0
  39. package/dist/types/index.js.map +1 -0
  40. package/dist/validation/SchemaValidator.d.ts +25 -0
  41. package/dist/validation/SchemaValidator.d.ts.map +1 -0
  42. package/dist/validation/SchemaValidator.js +125 -0
  43. package/dist/validation/SchemaValidator.js.map +1 -0
  44. package/jest.config.json +20 -0
  45. package/package.json +74 -0
  46. package/samples/sample_config.json +52 -0
  47. package/samples/study_1/series_1/CT.1.1.dcm +0 -0
  48. package/samples/study_1/series_1/CT.1.2.dcm +0 -0
  49. package/samples/study_1/series_1/CT.1.3.dcm +0 -0
  50. package/samples/study_1/series_1/CT.1.4.dcm +0 -0
  51. package/samples/study_1/series_1/CT.1.5.dcm +0 -0
  52. package/samples/study_1/series_2/CT.2.1.dcm +0 -0
  53. package/samples/study_1/series_2/CT.2.2.dcm +0 -0
  54. package/samples/study_1/series_2/CT.2.3.dcm +0 -0
  55. package/samples/study_1/series_2/CT.2.4.dcm +0 -0
  56. package/samples/study_1/series_2/CT.2.5.dcm +0 -0
  57. package/samples/study_1/series_3/CT.3.1.dcm +0 -0
  58. package/samples/study_1/series_3/CT.3.2.dcm +0 -0
  59. package/samples/study_1/series_3/CT.3.3.dcm +0 -0
  60. package/samples/study_1/series_3/CT.3.4.dcm +0 -0
  61. package/samples/study_1/series_3/CT.3.5.dcm +0 -0
  62. package/src/JmixBuilder.ts +572 -0
  63. package/src/crypto/PayloadDecryptor.ts +45 -0
  64. package/src/crypto/PayloadEncryptor.ts +97 -0
  65. package/src/dicom/DicomProcessor.ts +262 -0
  66. package/src/index.ts +30 -0
  67. package/src/types/daikon.d.ts +1 -0
  68. package/src/types/index.ts +201 -0
  69. package/src/validation/SchemaValidator.ts +156 -0
  70. package/test-encrypt.js +10 -0
  71. package/tests/JmixBuilder.test.ts +170 -0
  72. package/tests/SchemaValidator.test.ts +196 -0
  73. 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
+ }