@striae-org/striae 5.5.2 → 6.0.1
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/README.md +3 -1
- package/app/components/actions/case-export/download-handlers.ts +130 -62
- package/app/components/actions/case-manage/archive-package-builder.ts +299 -0
- package/app/components/actions/case-manage/delete-helpers.ts +61 -0
- package/app/components/actions/case-manage/index.ts +2 -0
- package/app/components/actions/case-manage/operations.ts +714 -0
- package/app/components/actions/case-manage/types.ts +21 -0
- package/app/components/actions/case-manage/utils.ts +34 -0
- package/app/components/actions/case-manage.ts +1 -1079
- package/app/components/navbar/case-import/case-import.module.css +2 -2
- package/app/components/navbar/case-import/case-import.tsx +0 -8
- package/app/components/navbar/case-import/components/CasePreviewSection.tsx +1 -1
- package/app/components/navbar/case-modals/all-cases-modal.tsx +13 -1
- package/app/components/navbar/navbar.tsx +8 -5
- package/app/components/sidebar/cases/case-sidebar.tsx +3 -2
- package/app/components/sidebar/sidebar-container.tsx +7 -0
- package/{members.emails.example → app/config-example/members.emails} +1 -1
- package/{primershear.emails.example → app/config-example/primershear.emails} +1 -1
- package/app/routes/striae/striae.tsx +36 -11
- package/app/types/export.ts +1 -0
- package/app/utils/forensics/SHA256.ts +2 -2
- package/app/utils/forensics/audit-export-signature.ts +1 -1
- package/app/utils/forensics/confirmation-signature.ts +1 -1
- package/app/utils/forensics/signature-utils.ts +7 -2
- package/package.json +2 -4
- package/scripts/deploy-config.sh +33 -0
- package/scripts/deploy-members-emails.sh +4 -4
- package/scripts/deploy-primershear-emails.sh +3 -3
- package/workers/audit-worker/package.json +1 -1
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/package.json +1 -1
- package/workers/data-worker/src/signature-utils.ts +7 -2
- package/workers/data-worker/src/signing-payload-utils.ts +4 -4
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/package.json +1 -1
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/package.json +1 -1
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/package.json +1 -1
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@ This npm package publishes the Striae application source for teams that run/deve
|
|
|
8
8
|
|
|
9
9
|
- Application: [https://striae.app](https://striae.app)
|
|
10
10
|
- Source repository: [https://github.com/striae-org/striae](https://github.com/striae-org/striae)
|
|
11
|
+
- Installation guide: [https://github.com/striae-org/striae/wiki/Installation-Guide](https://github.com/striae-org/striae/wiki/Installation-Guide)
|
|
11
12
|
- Releases: [https://github.com/striae-org/striae/releases](https://github.com/striae-org/striae/releases)
|
|
12
13
|
- Security policy: [https://github.com/striae-org/striae/security/policy](https://github.com/striae-org/striae/security/policy)
|
|
13
14
|
|
|
@@ -55,9 +56,10 @@ Excluded (by design):
|
|
|
55
56
|
|
|
56
57
|
## License
|
|
57
58
|
|
|
58
|
-
See `LICENSE
|
|
59
|
+
See `LICENSE`.
|
|
59
60
|
|
|
60
61
|
## Support
|
|
61
62
|
|
|
63
|
+
- Striae Community: [https://community.striae.org](https://community.striae.org)
|
|
62
64
|
- Support page: [https://www.striae.org/support](https://www.striae.org/support)
|
|
63
65
|
- Contact: [info@striae.org](mailto:info@striae.org)
|
|
@@ -8,13 +8,14 @@ import {
|
|
|
8
8
|
getCurrentPublicSigningKeyDetails,
|
|
9
9
|
getVerificationPublicKey,
|
|
10
10
|
getCurrentEncryptionPublicKeyDetails,
|
|
11
|
-
encryptExportDataWithAllImages
|
|
11
|
+
encryptExportDataWithAllImages,
|
|
12
12
|
} from '~/utils/forensics';
|
|
13
13
|
import { signForensicManifest } from '~/utils/data';
|
|
14
14
|
import { formatDateForFilename } from './types-constants';
|
|
15
15
|
import { addForensicDataWarning } from './metadata-helpers';
|
|
16
16
|
import { exportCaseData } from './core-export';
|
|
17
17
|
import { auditService } from '~/services/audit';
|
|
18
|
+
import { buildArchivePackage } from '~/components/actions/case-manage/archive-package-builder';
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Generate export filename with embedded ID to prevent collisions
|
|
@@ -23,15 +24,15 @@ import { auditService } from '~/services/audit';
|
|
|
23
24
|
*/
|
|
24
25
|
function generateExportFilename(originalFilename: string, id: string): string {
|
|
25
26
|
const lastDotIndex = originalFilename.lastIndexOf('.');
|
|
26
|
-
|
|
27
|
+
|
|
27
28
|
if (lastDotIndex === -1) {
|
|
28
29
|
// No extension found
|
|
29
30
|
return `${originalFilename}-${id}`;
|
|
30
31
|
}
|
|
31
|
-
|
|
32
|
+
|
|
32
33
|
const basename = originalFilename.substring(0, lastDotIndex);
|
|
33
34
|
const extension = originalFilename.substring(lastDotIndex);
|
|
34
|
-
|
|
35
|
+
|
|
35
36
|
return `${basename}-${id}${extension}`;
|
|
36
37
|
}
|
|
37
38
|
|
|
@@ -73,25 +74,99 @@ export async function downloadCaseAsZip(
|
|
|
73
74
|
let manifestSigned = false;
|
|
74
75
|
let publicKeyFileName: string | undefined;
|
|
75
76
|
const protectForensicData = true;
|
|
76
|
-
|
|
77
|
+
|
|
77
78
|
try {
|
|
78
79
|
// Start audit workflow
|
|
79
80
|
auditService.startWorkflow(caseNumber);
|
|
80
|
-
|
|
81
|
+
|
|
81
82
|
onProgress?.(10);
|
|
82
|
-
|
|
83
|
+
|
|
83
84
|
// Get case data
|
|
84
85
|
const exportData = await exportCaseData(user, caseNumber, options);
|
|
85
86
|
onProgress?.(30);
|
|
86
|
-
|
|
87
|
+
|
|
88
|
+
const archivePackageMode = options.archivePackageMode;
|
|
89
|
+
|
|
90
|
+
if (archivePackageMode) {
|
|
91
|
+
const archivedAt = exportData.metadata.archivedAt || new Date().toISOString();
|
|
92
|
+
const archivedByDisplay =
|
|
93
|
+
exportData.metadata.archivedByDisplay ||
|
|
94
|
+
exportData.metadata.archivedBy ||
|
|
95
|
+
exportData.metadata.exportedByName ||
|
|
96
|
+
exportData.metadata.exportedBy ||
|
|
97
|
+
'Unknown';
|
|
98
|
+
const caseJsonContent = await generateJSONContent(
|
|
99
|
+
exportData,
|
|
100
|
+
options.includeUserInfo,
|
|
101
|
+
protectForensicData
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const archivePackage = await buildArchivePackage({
|
|
105
|
+
user,
|
|
106
|
+
caseNumber,
|
|
107
|
+
caseJsonContent,
|
|
108
|
+
files: exportData.files,
|
|
109
|
+
auditConfig: {
|
|
110
|
+
startDate: exportData.metadata.caseCreatedDate,
|
|
111
|
+
endDate: archivedAt,
|
|
112
|
+
},
|
|
113
|
+
readmeConfig: {
|
|
114
|
+
archivedAt,
|
|
115
|
+
archivedByDisplay,
|
|
116
|
+
archiveReason: exportData.metadata.archiveReason,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
manifestSignatureKeyId = archivePackage.manifestSignatureKeyId;
|
|
121
|
+
manifestSigned = true;
|
|
122
|
+
onProgress?.(95);
|
|
123
|
+
|
|
124
|
+
const url = URL.createObjectURL(archivePackage.zipBlob);
|
|
125
|
+
const exportFileName = `striae-case-${caseNumber}-archive-${formatDateForFilename(new Date())}-encrypted.zip`;
|
|
126
|
+
|
|
127
|
+
const linkElement = document.createElement('a');
|
|
128
|
+
linkElement.href = url;
|
|
129
|
+
linkElement.setAttribute('download', exportFileName);
|
|
130
|
+
linkElement.setAttribute('title', 'Encrypted Striae case package');
|
|
131
|
+
linkElement.click();
|
|
132
|
+
|
|
133
|
+
URL.revokeObjectURL(url);
|
|
134
|
+
onProgress?.(100);
|
|
135
|
+
|
|
136
|
+
const endTime = Date.now();
|
|
137
|
+
await auditService.logCaseExport(
|
|
138
|
+
user,
|
|
139
|
+
caseNumber,
|
|
140
|
+
exportFileName,
|
|
141
|
+
'success',
|
|
142
|
+
[],
|
|
143
|
+
{
|
|
144
|
+
processingTimeMs: endTime - startTime,
|
|
145
|
+
fileSizeBytes: archivePackage.zipBlob.size,
|
|
146
|
+
validationStepsCompleted: exportData.files?.length || 0,
|
|
147
|
+
validationStepsFailed: 0,
|
|
148
|
+
},
|
|
149
|
+
'zip',
|
|
150
|
+
protectForensicData,
|
|
151
|
+
{
|
|
152
|
+
present: true,
|
|
153
|
+
valid: true,
|
|
154
|
+
keyId: manifestSignatureKeyId,
|
|
155
|
+
}
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
auditService.endWorkflow();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
87
162
|
// Create ZIP
|
|
88
163
|
const JSZip = (await import('jszip')).default;
|
|
89
164
|
const zip = new JSZip();
|
|
90
|
-
|
|
165
|
+
|
|
91
166
|
const jsonContent = await generateJSONContent(exportData, options.includeUserInfo, protectForensicData);
|
|
92
167
|
zip.file(`${caseNumber}_data.json`, jsonContent);
|
|
93
168
|
onProgress?.(50);
|
|
94
|
-
|
|
169
|
+
|
|
95
170
|
// Add images and collect them for manifest generation
|
|
96
171
|
const imageFolder = zip.folder('images');
|
|
97
172
|
const imageFiles: { [filename: string]: Blob } = {};
|
|
@@ -101,7 +176,6 @@ export async function downloadCaseAsZip(
|
|
|
101
176
|
try {
|
|
102
177
|
const imageBlob = await fetchImageAsBlob(user, file.fileData, caseNumber);
|
|
103
178
|
if (imageBlob) {
|
|
104
|
-
// Generate export filename with embedded ID to prevent collisions
|
|
105
179
|
const exportFilename = generateExportFilename(file.fileData.originalFilename, file.fileData.id);
|
|
106
180
|
imageFolder.file(exportFilename, imageBlob);
|
|
107
181
|
imageFiles[exportFilename] = imageBlob;
|
|
@@ -112,9 +186,7 @@ export async function downloadCaseAsZip(
|
|
|
112
186
|
onProgress?.(50 + (i / exportData.files.length) * 30);
|
|
113
187
|
}
|
|
114
188
|
}
|
|
115
|
-
|
|
116
|
-
// CRITICAL: Get the content that will be used for hash calculation.
|
|
117
|
-
// This must match the exported package content before encryption.
|
|
189
|
+
|
|
118
190
|
const contentForHash = await generateJSONContent(exportData, options.includeUserInfo, false);
|
|
119
191
|
|
|
120
192
|
const forensicManifest = await generateForensicManifestSecure(contentForHash, imageFiles);
|
|
@@ -128,7 +200,7 @@ export async function downloadCaseAsZip(
|
|
|
128
200
|
const signedForensicManifest = {
|
|
129
201
|
...forensicManifest,
|
|
130
202
|
manifestVersion: signingResult.manifestVersion,
|
|
131
|
-
signature: signingResult.signature
|
|
203
|
+
signature: signingResult.signature,
|
|
132
204
|
};
|
|
133
205
|
|
|
134
206
|
zip.file('FORENSIC_MANIFEST.json', JSON.stringify(signedForensicManifest, null, 2));
|
|
@@ -143,24 +215,30 @@ export async function downloadCaseAsZip(
|
|
|
143
215
|
}
|
|
144
216
|
|
|
145
217
|
try {
|
|
146
|
-
const
|
|
147
|
-
filename,
|
|
148
|
-
|
|
149
|
-
|
|
218
|
+
const filesToEncrypt = [
|
|
219
|
+
...Object.entries(imageFiles).map(([filename, blob]) => ({
|
|
220
|
+
filename,
|
|
221
|
+
blob,
|
|
222
|
+
})),
|
|
223
|
+
];
|
|
150
224
|
|
|
151
225
|
const encryptionResult = await encryptExportDataWithAllImages(
|
|
152
226
|
contentForHash,
|
|
153
|
-
|
|
227
|
+
filesToEncrypt,
|
|
154
228
|
encKeyDetails.publicKeyPem,
|
|
155
229
|
encKeyDetails.keyId
|
|
156
230
|
);
|
|
157
231
|
|
|
158
232
|
zip.file(`${caseNumber}_data.json`, encryptionResult.ciphertext);
|
|
159
233
|
|
|
160
|
-
if (
|
|
161
|
-
for (let i = 0; i <
|
|
162
|
-
const originalFilename =
|
|
163
|
-
|
|
234
|
+
if (encryptionResult.encryptedImages.length > 0) {
|
|
235
|
+
for (let i = 0; i < filesToEncrypt.length; i++) {
|
|
236
|
+
const originalFilename = filesToEncrypt[i].filename;
|
|
237
|
+
const encryptedContent = encryptionResult.encryptedImages[i];
|
|
238
|
+
|
|
239
|
+
if (imageFolder) {
|
|
240
|
+
imageFolder.file(originalFilename, encryptedContent);
|
|
241
|
+
}
|
|
164
242
|
}
|
|
165
243
|
}
|
|
166
244
|
|
|
@@ -213,28 +291,27 @@ For questions about this export, contact your Striae system administrator.
|
|
|
213
291
|
);
|
|
214
292
|
zip.file('README.txt', readme);
|
|
215
293
|
onProgress?.(85);
|
|
216
|
-
|
|
217
|
-
const zipBlob = await zip.generateAsync({
|
|
294
|
+
|
|
295
|
+
const zipBlob = await zip.generateAsync({
|
|
218
296
|
type: 'blob',
|
|
219
297
|
compression: 'DEFLATE',
|
|
220
|
-
compressionOptions: { level: 6 }
|
|
298
|
+
compressionOptions: { level: 6 },
|
|
221
299
|
});
|
|
222
300
|
onProgress?.(95);
|
|
223
301
|
|
|
224
302
|
const url = URL.createObjectURL(zipBlob);
|
|
225
303
|
const exportFileName = `striae-case-${caseNumber}-encrypted-package-${formatDateForFilename(new Date())}.zip`;
|
|
226
|
-
|
|
304
|
+
|
|
227
305
|
const linkElement = document.createElement('a');
|
|
228
306
|
linkElement.href = url;
|
|
229
307
|
linkElement.setAttribute('download', exportFileName);
|
|
230
308
|
linkElement.setAttribute('title', 'Encrypted Striae case package');
|
|
231
|
-
|
|
309
|
+
|
|
232
310
|
linkElement.click();
|
|
233
|
-
|
|
311
|
+
|
|
234
312
|
URL.revokeObjectURL(url);
|
|
235
313
|
onProgress?.(100);
|
|
236
|
-
|
|
237
|
-
// Log successful export audit event (standard case)
|
|
314
|
+
|
|
238
315
|
const endTime = Date.now();
|
|
239
316
|
await auditService.logCaseExport(
|
|
240
317
|
user,
|
|
@@ -246,24 +323,21 @@ For questions about this export, contact your Striae system administrator.
|
|
|
246
323
|
processingTimeMs: endTime - startTime,
|
|
247
324
|
fileSizeBytes: zipBlob.size,
|
|
248
325
|
validationStepsCompleted: exportData.files?.length || 0,
|
|
249
|
-
validationStepsFailed: 0
|
|
326
|
+
validationStepsFailed: 0,
|
|
250
327
|
},
|
|
251
328
|
'zip',
|
|
252
329
|
protectForensicData,
|
|
253
330
|
{
|
|
254
331
|
present: true,
|
|
255
332
|
valid: true,
|
|
256
|
-
keyId: manifestSignatureKeyId
|
|
333
|
+
keyId: manifestSignatureKeyId,
|
|
257
334
|
}
|
|
258
335
|
);
|
|
259
|
-
|
|
260
|
-
// End audit workflow
|
|
336
|
+
|
|
261
337
|
auditService.endWorkflow();
|
|
262
|
-
|
|
263
338
|
} catch (error) {
|
|
264
339
|
console.error('ZIP export failed:', error);
|
|
265
|
-
|
|
266
|
-
// Log failed export audit event
|
|
340
|
+
|
|
267
341
|
const endTime = Date.now();
|
|
268
342
|
await auditService.logCaseExport(
|
|
269
343
|
user,
|
|
@@ -275,20 +349,19 @@ For questions about this export, contact your Striae system administrator.
|
|
|
275
349
|
processingTimeMs: endTime - startTime,
|
|
276
350
|
fileSizeBytes: 0,
|
|
277
351
|
validationStepsCompleted: 0,
|
|
278
|
-
validationStepsFailed: 1
|
|
352
|
+
validationStepsFailed: 1,
|
|
279
353
|
},
|
|
280
354
|
'zip',
|
|
281
355
|
protectForensicData,
|
|
282
356
|
{
|
|
283
357
|
present: manifestSigned,
|
|
284
358
|
valid: manifestSigned,
|
|
285
|
-
keyId: manifestSignatureKeyId
|
|
359
|
+
keyId: manifestSignatureKeyId,
|
|
286
360
|
}
|
|
287
361
|
);
|
|
288
|
-
|
|
289
|
-
// End audit workflow
|
|
362
|
+
|
|
290
363
|
auditService.endWorkflow();
|
|
291
|
-
|
|
364
|
+
|
|
292
365
|
throw new Error('Failed to export encrypted case package');
|
|
293
366
|
}
|
|
294
367
|
}
|
|
@@ -305,8 +378,8 @@ async function fetchImageAsBlob(user: User, fileData: FileData, caseNumber: stri
|
|
|
305
378
|
const signedResponse = await fetch(url, {
|
|
306
379
|
method: 'GET',
|
|
307
380
|
headers: {
|
|
308
|
-
|
|
309
|
-
}
|
|
381
|
+
Accept: 'application/octet-stream,image/*',
|
|
382
|
+
},
|
|
310
383
|
});
|
|
311
384
|
|
|
312
385
|
if (!signedResponse.ok) {
|
|
@@ -394,13 +467,12 @@ https://striae.app`;
|
|
|
394
467
|
* Generate JSON content for case export with forensic protection options
|
|
395
468
|
*/
|
|
396
469
|
async function generateJSONContent(
|
|
397
|
-
exportData: CaseExportData,
|
|
398
|
-
includeUserInfo: boolean = true,
|
|
470
|
+
exportData: CaseExportData,
|
|
471
|
+
includeUserInfo: boolean = true,
|
|
399
472
|
protectForensicData: boolean = true
|
|
400
473
|
): Promise<string> {
|
|
401
474
|
const jsonData = { ...exportData };
|
|
402
|
-
|
|
403
|
-
// Remove sensitive user info if not included
|
|
475
|
+
|
|
404
476
|
if (!includeUserInfo) {
|
|
405
477
|
if (jsonData.metadata.exportedBy) {
|
|
406
478
|
jsonData.metadata.exportedBy = '[User Info Excluded]';
|
|
@@ -418,28 +490,24 @@ async function generateJSONContent(
|
|
|
418
490
|
jsonData.metadata.exportedByBadgeId = '[User Info Excluded]';
|
|
419
491
|
}
|
|
420
492
|
}
|
|
421
|
-
|
|
493
|
+
|
|
422
494
|
const jsonString = JSON.stringify(jsonData, null, 2);
|
|
423
|
-
|
|
424
|
-
// Calculate hash for integrity verification
|
|
425
495
|
const hash = await calculateSHA256Secure(jsonString);
|
|
426
|
-
|
|
427
|
-
// Add hash to metadata
|
|
496
|
+
|
|
428
497
|
const finalJsonData = {
|
|
429
498
|
...jsonData,
|
|
430
499
|
metadata: {
|
|
431
500
|
...jsonData.metadata,
|
|
432
501
|
hash: hash.toUpperCase(),
|
|
433
|
-
integrityNote: 'Verify by recalculating SHA256 of this entire JSON content'
|
|
434
|
-
}
|
|
502
|
+
integrityNote: 'Verify by recalculating SHA256 of this entire JSON content',
|
|
503
|
+
},
|
|
435
504
|
};
|
|
436
|
-
|
|
505
|
+
|
|
437
506
|
const finalJsonString = JSON.stringify(finalJsonData, null, 2);
|
|
438
|
-
|
|
439
|
-
// Add forensic protection warning if enabled
|
|
507
|
+
|
|
440
508
|
if (protectForensicData) {
|
|
441
509
|
return addForensicDataWarning(finalJsonString);
|
|
442
510
|
}
|
|
443
|
-
|
|
511
|
+
|
|
444
512
|
return finalJsonString;
|
|
445
|
-
}
|
|
513
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import type { User } from 'firebase/auth';
|
|
2
|
+
import { type AuditTrail, type CaseExportData, type ValidationAuditEntry } from '~/types';
|
|
3
|
+
import { signForensicManifest } from '~/utils/data';
|
|
4
|
+
import {
|
|
5
|
+
calculateSHA256Secure,
|
|
6
|
+
createPublicSigningKeyFileName,
|
|
7
|
+
encryptExportDataWithAllImages,
|
|
8
|
+
generateForensicManifestSecure,
|
|
9
|
+
getCurrentEncryptionPublicKeyDetails,
|
|
10
|
+
getCurrentPublicSigningKeyDetails,
|
|
11
|
+
getVerificationPublicKey,
|
|
12
|
+
} from '~/utils/forensics';
|
|
13
|
+
import { signAuditExport } from '~/services/audit/audit-export-signing';
|
|
14
|
+
import { generateAuditSummary, sortAuditEntriesNewestFirst } from '~/services/audit/audit-query-helpers';
|
|
15
|
+
import { auditService } from '~/services/audit';
|
|
16
|
+
import { getImageUrl } from '../image-manage';
|
|
17
|
+
|
|
18
|
+
export interface ArchiveBundleAuditConfig {
|
|
19
|
+
startDate: string;
|
|
20
|
+
endDate: string;
|
|
21
|
+
additionalEntries?: ValidationAuditEntry[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ArchiveBundleReadmeConfig {
|
|
25
|
+
archivedAt: string;
|
|
26
|
+
archivedByDisplay: string;
|
|
27
|
+
archiveReason?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BuildArchivePackageInput {
|
|
31
|
+
user: User;
|
|
32
|
+
caseNumber: string;
|
|
33
|
+
caseJsonContent: string;
|
|
34
|
+
files: CaseExportData['files'];
|
|
35
|
+
auditConfig: ArchiveBundleAuditConfig;
|
|
36
|
+
readmeConfig: ArchiveBundleReadmeConfig;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface BuildArchivePackageResult {
|
|
40
|
+
zipBlob: Blob;
|
|
41
|
+
publicKeyFileName: string;
|
|
42
|
+
manifestSignatureKeyId: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function generateArchiveImageFilename(originalFilename: string, id: string): string {
|
|
46
|
+
const lastDotIndex = originalFilename.lastIndexOf('.');
|
|
47
|
+
|
|
48
|
+
if (lastDotIndex === -1) {
|
|
49
|
+
return `${originalFilename}-${id}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const basename = originalFilename.substring(0, lastDotIndex);
|
|
53
|
+
const extension = originalFilename.substring(lastDotIndex);
|
|
54
|
+
|
|
55
|
+
return `${basename}-${id}${extension}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getVerificationPublicSigningKey(preferredKeyId?: string): { keyId: string | null; publicKeyPem: string } {
|
|
59
|
+
const preferredKey = preferredKeyId ? getVerificationPublicKey(preferredKeyId) : null;
|
|
60
|
+
const currentDetails = getCurrentPublicSigningKeyDetails();
|
|
61
|
+
const resolvedPem = preferredKey ?? currentDetails.publicKeyPem;
|
|
62
|
+
const resolvedKeyId = preferredKey ? preferredKeyId ?? null : currentDetails.keyId;
|
|
63
|
+
|
|
64
|
+
if (!resolvedPem || resolvedPem.trim().length === 0) {
|
|
65
|
+
throw new Error('No public signing key is configured for archive packaging.');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
keyId: resolvedKeyId,
|
|
70
|
+
publicKeyPem: resolvedPem.endsWith('\n') ? resolvedPem : `${resolvedPem}\n`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function fetchImageAsBlob(user: User, fileData: CaseExportData['files'][number]['fileData'], caseNumber: string): Promise<Blob | null> {
|
|
75
|
+
try {
|
|
76
|
+
const imageAccess = await getImageUrl(user, fileData, caseNumber, 'Archive Package');
|
|
77
|
+
const { blob, revoke, url } = imageAccess;
|
|
78
|
+
|
|
79
|
+
if (!blob) {
|
|
80
|
+
const signedResponse = await fetch(url, {
|
|
81
|
+
method: 'GET',
|
|
82
|
+
headers: {
|
|
83
|
+
Accept: 'application/octet-stream,image/*',
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (!signedResponse.ok) {
|
|
88
|
+
throw new Error(`Signed URL fetch failed with status ${signedResponse.status}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return await signedResponse.blob();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
return blob;
|
|
96
|
+
} finally {
|
|
97
|
+
revoke();
|
|
98
|
+
}
|
|
99
|
+
} catch (error) {
|
|
100
|
+
console.error('Failed to fetch image for archive package:', error);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function buildArchivePackage(input: BuildArchivePackageInput): Promise<BuildArchivePackageResult> {
|
|
106
|
+
const { user, caseNumber, caseJsonContent, files, auditConfig, readmeConfig } = input;
|
|
107
|
+
|
|
108
|
+
const JSZip = (await import('jszip')).default;
|
|
109
|
+
const zip = new JSZip();
|
|
110
|
+
zip.file(`${caseNumber}_data.json`, caseJsonContent);
|
|
111
|
+
|
|
112
|
+
const imageFolder = zip.folder('images');
|
|
113
|
+
const imageBlobs: Record<string, Blob> = {};
|
|
114
|
+
if (imageFolder && files) {
|
|
115
|
+
for (const fileEntry of files) {
|
|
116
|
+
const imageBlob = await fetchImageAsBlob(user, fileEntry.fileData, caseNumber);
|
|
117
|
+
if (!imageBlob) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const exportFileName = generateArchiveImageFilename(
|
|
122
|
+
fileEntry.fileData.originalFilename,
|
|
123
|
+
fileEntry.fileData.id
|
|
124
|
+
);
|
|
125
|
+
imageFolder.file(exportFileName, imageBlob);
|
|
126
|
+
imageBlobs[exportFileName] = imageBlob;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const forensicManifest = await generateForensicManifestSecure(caseJsonContent, imageBlobs);
|
|
131
|
+
const manifestSigningResponse = await signForensicManifest(user, caseNumber, forensicManifest);
|
|
132
|
+
|
|
133
|
+
const signingKey = getVerificationPublicSigningKey(manifestSigningResponse.signature.keyId);
|
|
134
|
+
const publicKeyFileName = createPublicSigningKeyFileName(signingKey.keyId);
|
|
135
|
+
zip.file(publicKeyFileName, signingKey.publicKeyPem);
|
|
136
|
+
|
|
137
|
+
zip.file(
|
|
138
|
+
'FORENSIC_MANIFEST.json',
|
|
139
|
+
JSON.stringify(
|
|
140
|
+
{
|
|
141
|
+
...forensicManifest,
|
|
142
|
+
manifestVersion: manifestSigningResponse.manifestVersion,
|
|
143
|
+
signature: manifestSigningResponse.signature,
|
|
144
|
+
},
|
|
145
|
+
null,
|
|
146
|
+
2
|
|
147
|
+
)
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const auditEntries = await auditService.getAuditEntriesForUser(user.uid, {
|
|
151
|
+
caseNumber,
|
|
152
|
+
startDate: auditConfig.startDate,
|
|
153
|
+
endDate: auditConfig.endDate,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const auditEntriesWithExtras = sortAuditEntriesNewestFirst([
|
|
157
|
+
...auditEntries,
|
|
158
|
+
...(auditConfig.additionalEntries ?? []),
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
const auditTrail: AuditTrail = {
|
|
162
|
+
caseNumber,
|
|
163
|
+
workflowId: `${caseNumber}-archive-${Date.now()}`,
|
|
164
|
+
entries: auditEntriesWithExtras,
|
|
165
|
+
summary: generateAuditSummary(auditEntriesWithExtras),
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const auditTrailPayload = {
|
|
169
|
+
metadata: {
|
|
170
|
+
exportTimestamp: new Date().toISOString(),
|
|
171
|
+
exportVersion: '1.0',
|
|
172
|
+
totalEntries: auditTrail.summary.totalEvents,
|
|
173
|
+
application: 'Striae',
|
|
174
|
+
exportType: 'trail' as const,
|
|
175
|
+
scopeType: 'case' as const,
|
|
176
|
+
scopeIdentifier: caseNumber,
|
|
177
|
+
},
|
|
178
|
+
auditTrail,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const auditTrailRawContent = JSON.stringify(auditTrailPayload, null, 2);
|
|
182
|
+
const auditTrailHash = await calculateSHA256Secure(auditTrailRawContent);
|
|
183
|
+
const signedAuditExportPayload = await signAuditExport(
|
|
184
|
+
{
|
|
185
|
+
exportFormat: 'json',
|
|
186
|
+
exportType: 'trail',
|
|
187
|
+
generatedAt: auditTrailPayload.metadata.exportTimestamp,
|
|
188
|
+
totalEntries: auditTrail.summary.totalEvents,
|
|
189
|
+
hash: auditTrailHash.toUpperCase(),
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
user,
|
|
193
|
+
scopeType: 'case',
|
|
194
|
+
scopeIdentifier: caseNumber,
|
|
195
|
+
caseNumber,
|
|
196
|
+
}
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const signedAuditTrail = {
|
|
200
|
+
metadata: {
|
|
201
|
+
...auditTrailPayload.metadata,
|
|
202
|
+
hash: auditTrailHash.toUpperCase(),
|
|
203
|
+
signatureVersion: signedAuditExportPayload.signatureMetadata.signatureVersion,
|
|
204
|
+
signatureMetadata: signedAuditExportPayload.signatureMetadata,
|
|
205
|
+
signature: signedAuditExportPayload.signature,
|
|
206
|
+
},
|
|
207
|
+
auditTrail,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const auditTrailJson = JSON.stringify(signedAuditTrail, null, 2);
|
|
211
|
+
const auditSignatureJson = JSON.stringify(signedAuditExportPayload, null, 2);
|
|
212
|
+
zip.file('audit/case-audit-trail.json', auditTrailJson);
|
|
213
|
+
zip.file('audit/case-audit-signature.json', auditSignatureJson);
|
|
214
|
+
|
|
215
|
+
const encryptionKeyDetails = getCurrentEncryptionPublicKeyDetails();
|
|
216
|
+
|
|
217
|
+
if (!encryptionKeyDetails.publicKeyPem || !encryptionKeyDetails.keyId) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
'Archive encryption is mandatory. Your Striae instance does not have a configured encryption public key. ' +
|
|
220
|
+
'Please contact your administrator to set up export encryption.'
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const filesToEncrypt: Array<{ filename: string; blob: Blob }> = [
|
|
225
|
+
...Object.entries(imageBlobs).map(([filename, blob]) => ({
|
|
226
|
+
filename,
|
|
227
|
+
blob,
|
|
228
|
+
})),
|
|
229
|
+
{
|
|
230
|
+
filename: 'audit/case-audit-trail.json',
|
|
231
|
+
blob: new Blob([auditTrailJson], { type: 'application/json' }),
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
filename: 'audit/case-audit-signature.json',
|
|
235
|
+
blob: new Blob([auditSignatureJson], { type: 'application/json' }),
|
|
236
|
+
},
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
const encryptionResult = await encryptExportDataWithAllImages(
|
|
240
|
+
caseJsonContent,
|
|
241
|
+
filesToEncrypt,
|
|
242
|
+
encryptionKeyDetails.publicKeyPem,
|
|
243
|
+
encryptionKeyDetails.keyId
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
zip.file(`${caseNumber}_data.json`, encryptionResult.ciphertext);
|
|
247
|
+
|
|
248
|
+
for (let index = 0; index < filesToEncrypt.length; index += 1) {
|
|
249
|
+
const originalFilename = filesToEncrypt[index].filename;
|
|
250
|
+
const encryptedContent = encryptionResult.encryptedImages[index];
|
|
251
|
+
|
|
252
|
+
if (originalFilename.startsWith('audit/')) {
|
|
253
|
+
zip.file(originalFilename, encryptedContent);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (imageFolder) {
|
|
258
|
+
imageFolder.file(originalFilename, encryptedContent);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
zip.file('ENCRYPTION_MANIFEST.json', JSON.stringify(encryptionResult.encryptionManifest, null, 2));
|
|
263
|
+
|
|
264
|
+
zip.file(
|
|
265
|
+
'README.txt',
|
|
266
|
+
[
|
|
267
|
+
'Striae Archived Case Package',
|
|
268
|
+
'===========================',
|
|
269
|
+
'',
|
|
270
|
+
`Case Number: ${caseNumber}`,
|
|
271
|
+
`Archived At: ${readmeConfig.archivedAt}`,
|
|
272
|
+
`Archived By: ${readmeConfig.archivedByDisplay}`,
|
|
273
|
+
`Archive Reason: ${readmeConfig.archiveReason?.trim() || 'Not provided'}`,
|
|
274
|
+
'',
|
|
275
|
+
'Package Contents',
|
|
276
|
+
'- Case data JSON export with all image references',
|
|
277
|
+
'- images/ folder with exported image files (encrypted)',
|
|
278
|
+
'- Full case audit trail export and signed audit metadata',
|
|
279
|
+
'- Forensic manifest with server-side signature',
|
|
280
|
+
'- ENCRYPTION_MANIFEST.json with encryption metadata and encrypted image hashes',
|
|
281
|
+
`- ${publicKeyFileName} for verification`,
|
|
282
|
+
'',
|
|
283
|
+
'This package is intended for read-only review and verification workflows.',
|
|
284
|
+
'This package is encrypted. Only Striae can decrypt and re-import it.',
|
|
285
|
+
].join('\n')
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
const zipBlob = await zip.generateAsync({
|
|
289
|
+
type: 'blob',
|
|
290
|
+
compression: 'DEFLATE',
|
|
291
|
+
compressionOptions: { level: 6 },
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
zipBlob,
|
|
296
|
+
publicKeyFileName,
|
|
297
|
+
manifestSignatureKeyId: manifestSigningResponse.signature.keyId,
|
|
298
|
+
};
|
|
299
|
+
}
|