@striae-org/striae 4.0.3 → 4.2.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/.env.example +8 -0
- package/app/components/actions/case-export/core-export.ts +14 -8
- package/app/components/actions/case-export/data-processing.ts +1 -0
- package/app/components/actions/case-export/download-handlers.ts +7 -0
- package/app/components/actions/case-export/metadata-helpers.ts +2 -1
- package/app/components/actions/case-import/confirmation-import.ts +12 -2
- package/app/components/actions/case-import/orchestrator.ts +78 -32
- package/app/components/actions/case-import/storage-operations.ts +97 -8
- package/app/components/actions/case-import/zip-processing.ts +159 -86
- package/app/components/actions/case-manage.ts +430 -8
- package/app/components/actions/confirm-export.ts +13 -4
- package/app/components/actions/generate-pdf.ts +10 -2
- package/app/components/actions/image-manage.ts +77 -44
- package/app/components/audit/user-audit-viewer.tsx +137 -945
- package/app/components/audit/user-audit.module.css +41 -0
- package/app/components/audit/viewer/audit-activity-summary.tsx +52 -0
- package/app/components/audit/viewer/audit-entries-list.tsx +207 -0
- package/app/components/audit/viewer/audit-filters-panel.tsx +307 -0
- package/app/components/audit/viewer/audit-user-info-card.tsx +44 -0
- package/app/components/audit/viewer/audit-viewer-header.tsx +55 -0
- package/app/components/audit/viewer/audit-viewer-utils.ts +123 -0
- package/app/components/audit/viewer/types.ts +1 -0
- package/app/components/audit/viewer/use-audit-viewer-data.ts +186 -0
- package/app/components/audit/viewer/use-audit-viewer-export.ts +176 -0
- package/app/components/audit/viewer/use-audit-viewer-filters.ts +141 -0
- package/app/components/auth/mfa-enrollment.module.css +13 -5
- package/app/components/auth/mfa-verification.module.css +13 -5
- package/app/components/canvas/box-annotations/box-annotations.module.css +22 -18
- package/app/components/canvas/box-annotations/box-annotations.tsx +15 -0
- package/app/components/canvas/canvas.module.css +64 -54
- package/app/components/canvas/canvas.tsx +17 -16
- package/app/components/canvas/confirmation/confirmation.module.css +1 -0
- package/app/components/canvas/confirmation/confirmation.tsx +17 -47
- package/app/components/navbar/case-modals/archive-case-modal.module.css +110 -0
- package/app/components/navbar/case-modals/archive-case-modal.tsx +129 -0
- package/app/components/navbar/case-modals/open-case-modal.module.css +81 -0
- package/app/components/navbar/case-modals/open-case-modal.tsx +120 -0
- package/app/components/navbar/case-modals/rename-case-modal.module.css +81 -0
- package/app/components/navbar/case-modals/rename-case-modal.tsx +107 -0
- package/app/components/navbar/navbar.module.css +447 -0
- package/app/components/navbar/navbar.tsx +377 -0
- package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +2 -0
- package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +21 -51
- package/app/components/sidebar/case-export/case-export.module.css +1 -0
- package/app/components/sidebar/case-export/case-export.tsx +14 -77
- package/app/components/sidebar/case-import/case-import.module.css +25 -0
- package/app/components/sidebar/case-import/case-import.tsx +64 -40
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +20 -1
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +15 -0
- package/app/components/sidebar/cases/case-sidebar.tsx +25 -519
- package/app/components/sidebar/cases/cases-modal.module.css +45 -9
- package/app/components/sidebar/cases/cases-modal.tsx +16 -16
- package/app/components/sidebar/cases/cases.module.css +62 -21
- package/app/components/sidebar/files/files-modal.module.css +46 -10
- package/app/components/sidebar/files/files-modal.tsx +22 -23
- package/app/components/sidebar/notes/notes-editor-modal.module.css +49 -0
- package/app/components/sidebar/notes/notes-editor-modal.tsx +66 -0
- package/app/components/sidebar/notes/notes-modal.tsx +18 -17
- package/app/components/sidebar/notes/notes-sidebar.tsx +199 -113
- package/app/components/sidebar/notes/notes.module.css +155 -0
- package/app/components/sidebar/sidebar-container.tsx +15 -28
- package/app/components/sidebar/sidebar.module.css +7 -71
- package/app/components/sidebar/sidebar.tsx +24 -125
- package/app/components/sidebar/upload/image-upload-zone.module.css +13 -13
- package/app/components/toast/toast.module.css +2 -1
- package/app/components/toast/toast.tsx +16 -11
- package/app/components/user/delete-account.tsx +10 -31
- package/app/components/user/inactivity-warning.module.css +9 -6
- package/app/components/user/inactivity-warning.tsx +15 -2
- package/app/components/user/manage-profile.module.css +2 -0
- package/app/components/user/manage-profile.tsx +108 -40
- package/app/hooks/useOverlayDismiss.ts +116 -0
- package/app/routes/auth/login.example.tsx +19 -8
- package/app/routes/auth/login.tsx +785 -774
- package/app/routes/auth/passwordReset.module.css +23 -13
- package/app/routes/striae/striae.module.css +10 -3
- package/app/routes/striae/striae.tsx +477 -31
- package/app/routes.ts +7 -0
- package/app/services/audit/audit-export-csv.ts +2 -0
- package/app/services/audit/audit.service.ts +202 -32
- package/app/services/audit/builders/audit-entry-builder.ts +2 -1
- package/app/services/audit/builders/audit-event-builders-case-file.ts +43 -0
- package/app/services/audit/builders/audit-event-builders-user-security.ts +4 -2
- package/app/services/audit/builders/audit-event-builders-workflow.ts +8 -0
- package/app/services/audit/builders/index.ts +1 -0
- package/app/types/audit.ts +5 -2
- package/app/types/case.ts +29 -0
- package/app/types/import.ts +3 -0
- package/app/types/user.ts +1 -0
- package/app/utils/data/permissions.ts +17 -1
- package/app/utils/forensics/audit-export-signature.ts +5 -1
- package/app/utils/forensics/confirmation-signature.ts +3 -0
- package/app/utils/forensics/export-verification.ts +497 -22
- package/functions/api/pdf/[[path]].ts +32 -1
- package/load-context.ts +9 -0
- package/package.json +6 -2
- package/primershear.emails.example +6 -0
- package/scripts/deploy-pages-secrets.sh +6 -0
- package/scripts/deploy-primershear-emails.sh +167 -0
- package/worker-configuration.d.ts +7493 -7491
- package/workers/audit-worker/worker-configuration.d.ts +7448 -11323
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/worker-configuration.d.ts +7448 -11323
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/worker-configuration.d.ts +7447 -11322
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/keys-worker/worker-configuration.d.ts +7447 -11322
- package/workers/keys-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/src/formats/format-striae.ts +8 -7
- package/workers/pdf-worker/src/pdf-worker.example.ts +3 -0
- package/workers/pdf-worker/src/report-types.ts +3 -0
- package/workers/pdf-worker/worker-configuration.d.ts +7448 -11323
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/src/user-worker.example.ts +6 -1
- package/workers/user-worker/worker-configuration.d.ts +7448 -11323
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
- package/public/.well-known/keybase.txt +0 -56
|
@@ -1,22 +1,74 @@
|
|
|
1
1
|
import { type ConfirmationImportData } from '~/types';
|
|
2
2
|
import {
|
|
3
3
|
extractForensicManifestData,
|
|
4
|
+
type ManifestSignatureVerificationResult,
|
|
4
5
|
type SignedForensicManifest,
|
|
5
6
|
calculateSHA256Secure,
|
|
6
7
|
validateCaseIntegritySecure,
|
|
7
8
|
verifyForensicManifestSignature
|
|
8
9
|
} from './SHA256';
|
|
10
|
+
import {
|
|
11
|
+
type AuditExportSigningPayload,
|
|
12
|
+
verifyAuditExportSignature
|
|
13
|
+
} from './audit-export-signature';
|
|
9
14
|
import { verifyConfirmationSignature } from './confirmation-signature';
|
|
10
15
|
|
|
11
16
|
export interface ExportVerificationResult {
|
|
12
17
|
isValid: boolean;
|
|
13
18
|
message: string;
|
|
14
|
-
exportType?: 'case-zip' | 'confirmation';
|
|
19
|
+
exportType?: 'case-zip' | 'confirmation' | 'audit-json';
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
const CASE_EXPORT_FILE_REGEX = /_data\.(json|csv)$/i;
|
|
18
23
|
const CONFIRMATION_EXPORT_FILE_REGEX = /^confirmation-data-.*\.json$/i;
|
|
19
24
|
|
|
25
|
+
interface BundledAuditExportFile {
|
|
26
|
+
metadata?: {
|
|
27
|
+
exportTimestamp?: string;
|
|
28
|
+
exportVersion?: string;
|
|
29
|
+
totalEntries?: number;
|
|
30
|
+
application?: string;
|
|
31
|
+
exportType?: 'entries' | 'trail' | 'report';
|
|
32
|
+
scopeType?: 'case' | 'user';
|
|
33
|
+
scopeIdentifier?: string;
|
|
34
|
+
hash?: string;
|
|
35
|
+
signatureVersion?: string;
|
|
36
|
+
signatureMetadata?: Partial<AuditExportSigningPayload>;
|
|
37
|
+
signature?: {
|
|
38
|
+
algorithm: string;
|
|
39
|
+
keyId: string;
|
|
40
|
+
signedAt: string;
|
|
41
|
+
value: string;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
auditTrail?: unknown;
|
|
45
|
+
auditEntries?: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface StandaloneAuditExportFile extends BundledAuditExportFile {
|
|
49
|
+
metadata?: BundledAuditExportFile['metadata'] & {
|
|
50
|
+
integrityNote?: string;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface CasePackageIntegrityInput {
|
|
55
|
+
cleanedContent: string;
|
|
56
|
+
imageFiles: Record<string, Blob>;
|
|
57
|
+
forensicManifest: SignedForensicManifest;
|
|
58
|
+
verificationPublicKeyPem?: string;
|
|
59
|
+
bundledAuditFiles?: {
|
|
60
|
+
auditTrailContent?: string;
|
|
61
|
+
auditSignatureContent?: string;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface CasePackageIntegrityResult {
|
|
66
|
+
isValid: boolean;
|
|
67
|
+
signatureResult: ManifestSignatureVerificationResult;
|
|
68
|
+
integrityResult: Awaited<ReturnType<typeof validateCaseIntegritySecure>>;
|
|
69
|
+
bundledAuditVerification: ExportVerificationResult | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
20
72
|
function createVerificationResult(
|
|
21
73
|
isValid: boolean,
|
|
22
74
|
message: string,
|
|
@@ -31,7 +83,7 @@ function createVerificationResult(
|
|
|
31
83
|
|
|
32
84
|
function getSignatureFailureMessage(
|
|
33
85
|
error: string | undefined,
|
|
34
|
-
targetLabel: 'export ZIP' | 'confirmation file'
|
|
86
|
+
targetLabel: 'export ZIP' | 'confirmation file' | 'audit export'
|
|
35
87
|
): string {
|
|
36
88
|
if (error?.includes('invalid public key')) {
|
|
37
89
|
return 'The selected PEM file is not a valid public key.';
|
|
@@ -62,6 +114,249 @@ function isConfirmationImportCandidate(candidate: unknown): candidate is Partial
|
|
|
62
114
|
);
|
|
63
115
|
}
|
|
64
116
|
|
|
117
|
+
function isAuditExportCandidate(candidate: unknown): candidate is StandaloneAuditExportFile {
|
|
118
|
+
if (!candidate || typeof candidate !== 'object') {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const auditCandidate = candidate as StandaloneAuditExportFile;
|
|
123
|
+
const metadata = auditCandidate.metadata;
|
|
124
|
+
|
|
125
|
+
if (!metadata || typeof metadata !== 'object') {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
typeof metadata.exportTimestamp === 'string' &&
|
|
131
|
+
typeof metadata.exportType === 'string' &&
|
|
132
|
+
typeof metadata.scopeType === 'string' &&
|
|
133
|
+
typeof metadata.scopeIdentifier === 'string' &&
|
|
134
|
+
typeof metadata.hash === 'string' &&
|
|
135
|
+
!!metadata.signature &&
|
|
136
|
+
(auditCandidate.auditTrail !== undefined || auditCandidate.auditEntries !== undefined)
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function verifyAuditExportContent(
|
|
141
|
+
fileContent: string,
|
|
142
|
+
verificationPublicKeyPem: string
|
|
143
|
+
): Promise<ExportVerificationResult> {
|
|
144
|
+
try {
|
|
145
|
+
const parsedContent = JSON.parse(fileContent) as unknown;
|
|
146
|
+
|
|
147
|
+
if (!isAuditExportCandidate(parsedContent)) {
|
|
148
|
+
return createVerificationResult(
|
|
149
|
+
false,
|
|
150
|
+
'The JSON file is not a supported Striae audit export.',
|
|
151
|
+
'audit-json'
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const auditExport = parsedContent as StandaloneAuditExportFile;
|
|
156
|
+
const metadata = auditExport.metadata!;
|
|
157
|
+
|
|
158
|
+
const unsignedAuditExport = auditExport.auditTrail !== undefined
|
|
159
|
+
? {
|
|
160
|
+
metadata: {
|
|
161
|
+
exportTimestamp: metadata.exportTimestamp,
|
|
162
|
+
exportVersion: metadata.exportVersion,
|
|
163
|
+
totalEntries: metadata.totalEntries,
|
|
164
|
+
application: metadata.application,
|
|
165
|
+
exportType: metadata.exportType,
|
|
166
|
+
scopeType: metadata.scopeType,
|
|
167
|
+
scopeIdentifier: metadata.scopeIdentifier,
|
|
168
|
+
},
|
|
169
|
+
auditTrail: auditExport.auditTrail,
|
|
170
|
+
}
|
|
171
|
+
: {
|
|
172
|
+
metadata: {
|
|
173
|
+
exportTimestamp: metadata.exportTimestamp,
|
|
174
|
+
exportVersion: metadata.exportVersion,
|
|
175
|
+
totalEntries: metadata.totalEntries,
|
|
176
|
+
application: metadata.application,
|
|
177
|
+
exportType: metadata.exportType,
|
|
178
|
+
scopeType: metadata.scopeType,
|
|
179
|
+
scopeIdentifier: metadata.scopeIdentifier,
|
|
180
|
+
},
|
|
181
|
+
auditEntries: auditExport.auditEntries,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const recalculatedHash = await calculateSHA256Secure(JSON.stringify(unsignedAuditExport, null, 2));
|
|
185
|
+
const hashValid = recalculatedHash.toUpperCase() === metadata.hash!.toUpperCase();
|
|
186
|
+
|
|
187
|
+
const signaturePayload: Partial<AuditExportSigningPayload> = {
|
|
188
|
+
signatureVersion: metadata.signatureVersion,
|
|
189
|
+
exportFormat: 'json',
|
|
190
|
+
exportType: metadata.exportType,
|
|
191
|
+
scopeType: metadata.scopeType,
|
|
192
|
+
scopeIdentifier: metadata.scopeIdentifier,
|
|
193
|
+
generatedAt: metadata.exportTimestamp,
|
|
194
|
+
totalEntries: metadata.totalEntries,
|
|
195
|
+
hash: metadata.hash,
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const signatureResult = await verifyAuditExportSignature(
|
|
199
|
+
signaturePayload,
|
|
200
|
+
metadata.signature,
|
|
201
|
+
verificationPublicKeyPem
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
if (hashValid && signatureResult.isValid) {
|
|
205
|
+
return createVerificationResult(
|
|
206
|
+
true,
|
|
207
|
+
'The audit export passed signature and integrity verification.',
|
|
208
|
+
'audit-json'
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!hashValid && !signatureResult.isValid) {
|
|
213
|
+
return createVerificationResult(
|
|
214
|
+
false,
|
|
215
|
+
'The audit export failed signature and integrity verification.',
|
|
216
|
+
'audit-json'
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!signatureResult.isValid) {
|
|
221
|
+
return createVerificationResult(
|
|
222
|
+
false,
|
|
223
|
+
getSignatureFailureMessage(signatureResult.error, 'audit export'),
|
|
224
|
+
'audit-json'
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return createVerificationResult(
|
|
229
|
+
false,
|
|
230
|
+
'The audit export failed integrity verification.',
|
|
231
|
+
'audit-json'
|
|
232
|
+
);
|
|
233
|
+
} catch {
|
|
234
|
+
return createVerificationResult(
|
|
235
|
+
false,
|
|
236
|
+
'The JSON file could not be read as a supported Striae audit export.',
|
|
237
|
+
'audit-json'
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function verifyBundledAuditExport(
|
|
243
|
+
zip: {
|
|
244
|
+
file: (path: string) => { async: (type: 'text') => Promise<string> } | null;
|
|
245
|
+
},
|
|
246
|
+
verificationPublicKeyPem: string
|
|
247
|
+
): Promise<ExportVerificationResult | null> {
|
|
248
|
+
const auditTrailContent = await zip.file('audit/case-audit-trail.json')?.async('text');
|
|
249
|
+
const auditSignatureContent = await zip.file('audit/case-audit-signature.json')?.async('text');
|
|
250
|
+
|
|
251
|
+
if (!auditTrailContent && !auditSignatureContent) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (!auditTrailContent || !auditSignatureContent) {
|
|
256
|
+
return createVerificationResult(
|
|
257
|
+
false,
|
|
258
|
+
'The archive ZIP contains incomplete bundled audit verification files.',
|
|
259
|
+
'case-zip'
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
const auditTrailExport = JSON.parse(auditTrailContent) as BundledAuditExportFile;
|
|
265
|
+
const auditSignatureExport = JSON.parse(auditSignatureContent) as {
|
|
266
|
+
signatureMetadata?: Partial<AuditExportSigningPayload>;
|
|
267
|
+
signature?: NonNullable<BundledAuditExportFile['metadata']>['signature'];
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const metadata = auditTrailExport.metadata;
|
|
271
|
+
if (!metadata?.signature || typeof metadata.hash !== 'string') {
|
|
272
|
+
return createVerificationResult(
|
|
273
|
+
false,
|
|
274
|
+
'The bundled audit export is missing required hash or signature metadata.',
|
|
275
|
+
'case-zip'
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const unsignedAuditExport = auditTrailExport.auditTrail !== undefined
|
|
280
|
+
? {
|
|
281
|
+
metadata: {
|
|
282
|
+
exportTimestamp: metadata.exportTimestamp,
|
|
283
|
+
exportVersion: metadata.exportVersion,
|
|
284
|
+
totalEntries: metadata.totalEntries,
|
|
285
|
+
application: metadata.application,
|
|
286
|
+
exportType: metadata.exportType,
|
|
287
|
+
scopeType: metadata.scopeType,
|
|
288
|
+
scopeIdentifier: metadata.scopeIdentifier,
|
|
289
|
+
},
|
|
290
|
+
auditTrail: auditTrailExport.auditTrail,
|
|
291
|
+
}
|
|
292
|
+
: {
|
|
293
|
+
metadata: {
|
|
294
|
+
exportTimestamp: metadata.exportTimestamp,
|
|
295
|
+
exportVersion: metadata.exportVersion,
|
|
296
|
+
totalEntries: metadata.totalEntries,
|
|
297
|
+
application: metadata.application,
|
|
298
|
+
exportType: metadata.exportType,
|
|
299
|
+
scopeType: metadata.scopeType,
|
|
300
|
+
scopeIdentifier: metadata.scopeIdentifier,
|
|
301
|
+
},
|
|
302
|
+
auditEntries: auditTrailExport.auditEntries,
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const recalculatedHash = await calculateSHA256Secure(JSON.stringify(unsignedAuditExport, null, 2));
|
|
306
|
+
if (recalculatedHash.toUpperCase() !== metadata.hash.toUpperCase()) {
|
|
307
|
+
return createVerificationResult(
|
|
308
|
+
false,
|
|
309
|
+
'The bundled audit export failed integrity verification.',
|
|
310
|
+
'case-zip'
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const embeddedSignaturePayload: Partial<AuditExportSigningPayload> = metadata.signatureMetadata ?? {
|
|
315
|
+
signatureVersion: metadata.signatureVersion,
|
|
316
|
+
exportFormat: 'json',
|
|
317
|
+
exportType: metadata.exportType,
|
|
318
|
+
scopeType: metadata.scopeType,
|
|
319
|
+
scopeIdentifier: metadata.scopeIdentifier,
|
|
320
|
+
generatedAt: metadata.exportTimestamp,
|
|
321
|
+
totalEntries: metadata.totalEntries,
|
|
322
|
+
hash: metadata.hash,
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const signatureVerification = await verifyAuditExportSignature(
|
|
326
|
+
embeddedSignaturePayload,
|
|
327
|
+
metadata.signature,
|
|
328
|
+
verificationPublicKeyPem
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
if (!signatureVerification.isValid) {
|
|
332
|
+
return createVerificationResult(
|
|
333
|
+
false,
|
|
334
|
+
getSignatureFailureMessage(signatureVerification.error, 'export ZIP'),
|
|
335
|
+
'case-zip'
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (
|
|
340
|
+
JSON.stringify(auditSignatureExport.signatureMetadata ?? null) !== JSON.stringify(metadata.signatureMetadata ?? null) ||
|
|
341
|
+
JSON.stringify(auditSignatureExport.signature ?? null) !== JSON.stringify(metadata.signature ?? null)
|
|
342
|
+
) {
|
|
343
|
+
return createVerificationResult(
|
|
344
|
+
false,
|
|
345
|
+
'The bundled audit signature artifact does not match the signed audit export.',
|
|
346
|
+
'case-zip'
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return null;
|
|
351
|
+
} catch {
|
|
352
|
+
return createVerificationResult(
|
|
353
|
+
false,
|
|
354
|
+
'The bundled audit export could not be parsed for verification.',
|
|
355
|
+
'case-zip'
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
65
360
|
/**
|
|
66
361
|
* Remove forensic warning from content for hash validation.
|
|
67
362
|
* Supports the warning formats added to JSON and CSV case exports.
|
|
@@ -174,8 +469,26 @@ async function verifyCaseZipExport(
|
|
|
174
469
|
})
|
|
175
470
|
);
|
|
176
471
|
|
|
177
|
-
const
|
|
178
|
-
|
|
472
|
+
const bundledAuditFiles = {
|
|
473
|
+
auditTrailContent: await zip.file('audit/case-audit-trail.json')?.async('text'),
|
|
474
|
+
auditSignatureContent: await zip.file('audit/case-audit-signature.json')?.async('text')
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const casePackageResult = await verifyCasePackageIntegrity({
|
|
478
|
+
cleanedContent,
|
|
479
|
+
imageFiles,
|
|
480
|
+
forensicManifest,
|
|
481
|
+
verificationPublicKeyPem,
|
|
482
|
+
bundledAuditFiles
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
const signatureResult = casePackageResult.signatureResult;
|
|
486
|
+
const integrityResult = casePackageResult.integrityResult;
|
|
487
|
+
const bundledAuditVerification = casePackageResult.bundledAuditVerification;
|
|
488
|
+
|
|
489
|
+
if (bundledAuditVerification) {
|
|
490
|
+
return bundledAuditVerification;
|
|
491
|
+
}
|
|
179
492
|
|
|
180
493
|
if (signatureResult.isValid && integrityResult.isValid) {
|
|
181
494
|
return createVerificationResult(
|
|
@@ -211,22 +524,6 @@ async function verifyCaseZipExport(
|
|
|
211
524
|
}
|
|
212
525
|
}
|
|
213
526
|
|
|
214
|
-
async function verifyConfirmationExport(
|
|
215
|
-
file: File,
|
|
216
|
-
verificationPublicKeyPem: string
|
|
217
|
-
): Promise<ExportVerificationResult> {
|
|
218
|
-
try {
|
|
219
|
-
const fileContent = await file.text();
|
|
220
|
-
return verifyConfirmationContent(fileContent, verificationPublicKeyPem);
|
|
221
|
-
} catch {
|
|
222
|
-
return createVerificationResult(
|
|
223
|
-
false,
|
|
224
|
-
'The JSON file could not be read as a supported Striae confirmation export.',
|
|
225
|
-
'confirmation'
|
|
226
|
-
);
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
527
|
async function verifyConfirmationContent(
|
|
231
528
|
fileContent: string,
|
|
232
529
|
verificationPublicKeyPem: string
|
|
@@ -343,11 +640,189 @@ export async function verifyExportFile(
|
|
|
343
640
|
}
|
|
344
641
|
|
|
345
642
|
if (lowerName.endsWith('.json')) {
|
|
346
|
-
|
|
643
|
+
try {
|
|
644
|
+
const fileContent = await file.text();
|
|
645
|
+
const parsedContent = JSON.parse(fileContent) as unknown;
|
|
646
|
+
|
|
647
|
+
if (isConfirmationImportCandidate(parsedContent)) {
|
|
648
|
+
return verifyConfirmationContent(fileContent, verificationPublicKeyPem);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (isAuditExportCandidate(parsedContent)) {
|
|
652
|
+
return verifyAuditExportContent(fileContent, verificationPublicKeyPem);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return createVerificationResult(
|
|
656
|
+
false,
|
|
657
|
+
'Select a confirmation JSON/ZIP file, standalone audit JSON export, or a case export ZIP file.'
|
|
658
|
+
);
|
|
659
|
+
} catch {
|
|
660
|
+
return createVerificationResult(
|
|
661
|
+
false,
|
|
662
|
+
'The JSON file could not be read as a supported Striae confirmation or audit export.'
|
|
663
|
+
);
|
|
664
|
+
}
|
|
347
665
|
}
|
|
348
666
|
|
|
349
667
|
return createVerificationResult(
|
|
350
668
|
false,
|
|
351
|
-
'Select a confirmation JSON/ZIP file or a case export ZIP file.'
|
|
669
|
+
'Select a confirmation JSON/ZIP file, standalone audit JSON export, or a case export ZIP file.'
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
export async function verifyCasePackageIntegrity(
|
|
674
|
+
input: CasePackageIntegrityInput
|
|
675
|
+
): Promise<CasePackageIntegrityResult> {
|
|
676
|
+
const manifestData = extractForensicManifestData(input.forensicManifest);
|
|
677
|
+
|
|
678
|
+
if (!manifestData) {
|
|
679
|
+
return {
|
|
680
|
+
isValid: false,
|
|
681
|
+
signatureResult: {
|
|
682
|
+
isValid: false,
|
|
683
|
+
error: 'Forensic manifest structure is invalid'
|
|
684
|
+
},
|
|
685
|
+
integrityResult: {
|
|
686
|
+
isValid: false,
|
|
687
|
+
dataValid: false,
|
|
688
|
+
imageValidation: {},
|
|
689
|
+
manifestValid: false,
|
|
690
|
+
errors: ['Forensic manifest structure is invalid'],
|
|
691
|
+
summary: 'Manifest validation failed'
|
|
692
|
+
},
|
|
693
|
+
bundledAuditVerification: null
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const signatureResult = await verifyForensicManifestSignature(
|
|
698
|
+
input.forensicManifest,
|
|
699
|
+
input.verificationPublicKeyPem
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
const integrityResult = await validateCaseIntegritySecure(
|
|
703
|
+
input.cleanedContent,
|
|
704
|
+
input.imageFiles,
|
|
705
|
+
manifestData
|
|
352
706
|
);
|
|
707
|
+
|
|
708
|
+
const bundledAuditVerification = input.bundledAuditFiles
|
|
709
|
+
? await (async () => {
|
|
710
|
+
const { auditTrailContent, auditSignatureContent } = input.bundledAuditFiles ?? {};
|
|
711
|
+
|
|
712
|
+
if (!auditTrailContent && !auditSignatureContent) {
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (!auditTrailContent || !auditSignatureContent) {
|
|
717
|
+
return createVerificationResult(
|
|
718
|
+
false,
|
|
719
|
+
'The archive ZIP contains incomplete bundled audit verification files.',
|
|
720
|
+
'case-zip'
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
try {
|
|
725
|
+
const auditTrailExport = JSON.parse(auditTrailContent) as BundledAuditExportFile;
|
|
726
|
+
const auditSignatureExport = JSON.parse(auditSignatureContent) as {
|
|
727
|
+
signatureMetadata?: Partial<AuditExportSigningPayload>;
|
|
728
|
+
signature?: NonNullable<BundledAuditExportFile['metadata']>['signature'];
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
const metadata = auditTrailExport.metadata;
|
|
732
|
+
if (!metadata?.signature || typeof metadata.hash !== 'string') {
|
|
733
|
+
return createVerificationResult(
|
|
734
|
+
false,
|
|
735
|
+
'The bundled audit export is missing required hash or signature metadata.',
|
|
736
|
+
'case-zip'
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const unsignedAuditExport = auditTrailExport.auditTrail !== undefined
|
|
741
|
+
? {
|
|
742
|
+
metadata: {
|
|
743
|
+
exportTimestamp: metadata.exportTimestamp,
|
|
744
|
+
exportVersion: metadata.exportVersion,
|
|
745
|
+
totalEntries: metadata.totalEntries,
|
|
746
|
+
application: metadata.application,
|
|
747
|
+
exportType: metadata.exportType,
|
|
748
|
+
scopeType: metadata.scopeType,
|
|
749
|
+
scopeIdentifier: metadata.scopeIdentifier,
|
|
750
|
+
},
|
|
751
|
+
auditTrail: auditTrailExport.auditTrail,
|
|
752
|
+
}
|
|
753
|
+
: {
|
|
754
|
+
metadata: {
|
|
755
|
+
exportTimestamp: metadata.exportTimestamp,
|
|
756
|
+
exportVersion: metadata.exportVersion,
|
|
757
|
+
totalEntries: metadata.totalEntries,
|
|
758
|
+
application: metadata.application,
|
|
759
|
+
exportType: metadata.exportType,
|
|
760
|
+
scopeType: metadata.scopeType,
|
|
761
|
+
scopeIdentifier: metadata.scopeIdentifier,
|
|
762
|
+
},
|
|
763
|
+
auditEntries: auditTrailExport.auditEntries,
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
const recalculatedHash = await calculateSHA256Secure(JSON.stringify(unsignedAuditExport, null, 2));
|
|
767
|
+
if (recalculatedHash.toUpperCase() !== metadata.hash.toUpperCase()) {
|
|
768
|
+
return createVerificationResult(
|
|
769
|
+
false,
|
|
770
|
+
'The bundled audit export failed integrity verification.',
|
|
771
|
+
'case-zip'
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const embeddedSignaturePayload: Partial<AuditExportSigningPayload> = metadata.signatureMetadata ?? {
|
|
776
|
+
signatureVersion: metadata.signatureVersion,
|
|
777
|
+
exportFormat: 'json',
|
|
778
|
+
exportType: metadata.exportType,
|
|
779
|
+
scopeType: metadata.scopeType,
|
|
780
|
+
scopeIdentifier: metadata.scopeIdentifier,
|
|
781
|
+
generatedAt: metadata.exportTimestamp,
|
|
782
|
+
totalEntries: metadata.totalEntries,
|
|
783
|
+
hash: metadata.hash,
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
const signatureVerification = await verifyAuditExportSignature(
|
|
787
|
+
embeddedSignaturePayload,
|
|
788
|
+
metadata.signature,
|
|
789
|
+
input.verificationPublicKeyPem
|
|
790
|
+
);
|
|
791
|
+
|
|
792
|
+
if (!signatureVerification.isValid) {
|
|
793
|
+
return createVerificationResult(
|
|
794
|
+
false,
|
|
795
|
+
getSignatureFailureMessage(signatureVerification.error, 'export ZIP'),
|
|
796
|
+
'case-zip'
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
if (
|
|
801
|
+
JSON.stringify(auditSignatureExport.signatureMetadata ?? null) !== JSON.stringify(metadata.signatureMetadata ?? null) ||
|
|
802
|
+
JSON.stringify(auditSignatureExport.signature ?? null) !== JSON.stringify(metadata.signature ?? null)
|
|
803
|
+
) {
|
|
804
|
+
return createVerificationResult(
|
|
805
|
+
false,
|
|
806
|
+
'The bundled audit signature artifact does not match the signed audit export.',
|
|
807
|
+
'case-zip'
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
return null;
|
|
812
|
+
} catch {
|
|
813
|
+
return createVerificationResult(
|
|
814
|
+
false,
|
|
815
|
+
'The bundled audit export could not be parsed for verification.',
|
|
816
|
+
'case-zip'
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
})()
|
|
820
|
+
: null;
|
|
821
|
+
|
|
822
|
+
return {
|
|
823
|
+
isValid: signatureResult.isValid && integrityResult.isValid && !bundledAuditVerification,
|
|
824
|
+
signatureResult,
|
|
825
|
+
integrityResult,
|
|
826
|
+
bundledAuditVerification
|
|
827
|
+
};
|
|
353
828
|
}
|
|
@@ -6,6 +6,8 @@ interface PdfProxyContext {
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
const SUPPORTED_METHODS = new Set(['POST', 'OPTIONS']);
|
|
9
|
+
const PRIMERSHEAR_FORMAT = 'primershear';
|
|
10
|
+
const DEFAULT_FORMAT = 'striae';
|
|
9
11
|
|
|
10
12
|
function textResponse(message: string, status: number): Response {
|
|
11
13
|
return new Response(message, {
|
|
@@ -40,6 +42,12 @@ function extractProxyPath(url: URL): string | null {
|
|
|
40
42
|
return remainder.length > 0 ? remainder : '/';
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
function resolveReportFormat(email: string | null, primershearEmails: string): string {
|
|
46
|
+
if (!email) return DEFAULT_FORMAT;
|
|
47
|
+
const allowed = primershearEmails.split(',').map(e => e.trim().toLowerCase()).filter(Boolean);
|
|
48
|
+
return allowed.includes(email.toLowerCase()) ? PRIMERSHEAR_FORMAT : DEFAULT_FORMAT;
|
|
49
|
+
}
|
|
50
|
+
|
|
43
51
|
export const onRequest = async ({ request, env }: PdfProxyContext): Promise<Response> => {
|
|
44
52
|
if (!SUPPORTED_METHODS.has(request.method)) {
|
|
45
53
|
return textResponse('Method not allowed', 405);
|
|
@@ -86,12 +94,35 @@ export const onRequest = async ({ request, env }: PdfProxyContext): Promise<Resp
|
|
|
86
94
|
|
|
87
95
|
upstreamHeaders.set('X-Custom-Auth-Key', env.PDF_WORKER_AUTH);
|
|
88
96
|
|
|
97
|
+
// Resolve the report format server-side based on the verified user email.
|
|
98
|
+
// This prevents email lists from ever being exposed in the client bundle.
|
|
99
|
+
const reportFormat = resolveReportFormat(
|
|
100
|
+
identity.email,
|
|
101
|
+
env.PRIMERSHEAR_EMAILS ?? ''
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
let upstreamBody: BodyInit;
|
|
105
|
+
try {
|
|
106
|
+
const payload = await request.json() as Record<string, unknown>;
|
|
107
|
+
// Inject the server-resolved format, overriding any client-supplied value.
|
|
108
|
+
if (payload.data && typeof payload.data === 'object') {
|
|
109
|
+
payload.reportFormat = reportFormat;
|
|
110
|
+
} else {
|
|
111
|
+
// Legacy flat payload shape
|
|
112
|
+
payload.reportFormat = reportFormat;
|
|
113
|
+
}
|
|
114
|
+
upstreamBody = JSON.stringify(payload);
|
|
115
|
+
upstreamHeaders.set('Content-Type', 'application/json');
|
|
116
|
+
} catch {
|
|
117
|
+
return textResponse('Invalid request body', 400);
|
|
118
|
+
}
|
|
119
|
+
|
|
89
120
|
let upstreamResponse: Response;
|
|
90
121
|
try {
|
|
91
122
|
upstreamResponse = await fetch(upstreamUrl, {
|
|
92
123
|
method: request.method,
|
|
93
124
|
headers: upstreamHeaders,
|
|
94
|
-
body:
|
|
125
|
+
body: upstreamBody
|
|
95
126
|
});
|
|
96
127
|
} catch {
|
|
97
128
|
return textResponse('Upstream PDF service unavailable', 502);
|
package/load-context.ts
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@striae-org/striae",
|
|
3
|
-
"version": "4.0
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Striae is a specialized, cloud-native platform designed to streamline forensic firearms identification by providing an intuitive environment for digital comparison image annotation, authenticated confirmations, and automated report generation.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -43,8 +43,10 @@
|
|
|
43
43
|
"app/entry.client.tsx",
|
|
44
44
|
"app/entry.server.tsx",
|
|
45
45
|
"app/root.tsx",
|
|
46
|
+
"app/routes.ts",
|
|
46
47
|
"app/tailwind.css",
|
|
47
48
|
"react-router.config.ts",
|
|
49
|
+
"load-context.ts",
|
|
48
50
|
"functions/",
|
|
49
51
|
"public/",
|
|
50
52
|
"scripts/",
|
|
@@ -60,6 +62,7 @@
|
|
|
60
62
|
"workers/pdf-worker/src/report-types.ts",
|
|
61
63
|
"workers/*/wrangler.jsonc.example",
|
|
62
64
|
".env.example",
|
|
65
|
+
"primershear.emails.example",
|
|
63
66
|
"firebase.json",
|
|
64
67
|
"postcss.config.js",
|
|
65
68
|
"tailwind.config.ts",
|
|
@@ -99,8 +102,9 @@
|
|
|
99
102
|
"install-workers": "bash ./scripts/install-workers.sh",
|
|
100
103
|
"deploy-workers": "npm run deploy-workers:audit && npm run deploy-workers:data && npm run deploy-workers:image && npm run deploy-workers:keys && npm run deploy-workers:pdf && npm run deploy-workers:user",
|
|
101
104
|
"deploy-workers:secrets": "bash ./scripts/deploy-worker-secrets.sh",
|
|
102
|
-
"deploy-pages:secrets": "bash ./scripts/deploy-pages-secrets.sh",
|
|
105
|
+
"deploy-pages:secrets": "bash ./scripts/deploy-pages-secrets.sh --production-only",
|
|
103
106
|
"deploy-pages": "bash ./scripts/deploy-pages.sh",
|
|
107
|
+
"deploy-primershear": "bash ./scripts/deploy-primershear-emails.sh --production-only",
|
|
104
108
|
"deploy-workers:audit": "cd workers/audit-worker && npm run deploy",
|
|
105
109
|
"deploy-workers:data": "cd workers/data-worker && npm run deploy",
|
|
106
110
|
"deploy-workers:image": "cd workers/image-worker && npm run deploy",
|
|
@@ -178,6 +178,12 @@ deploy_pages_environment_secrets() {
|
|
|
178
178
|
set_pages_secret "API_TOKEN" "$optional_api_token" "$pages_env"
|
|
179
179
|
fi
|
|
180
180
|
|
|
181
|
+
local optional_primershear_emails
|
|
182
|
+
optional_primershear_emails=$(get_optional_value "PRIMERSHEAR_EMAILS")
|
|
183
|
+
if [ -n "$optional_primershear_emails" ]; then
|
|
184
|
+
set_pages_secret "PRIMERSHEAR_EMAILS" "$optional_primershear_emails" "$pages_env"
|
|
185
|
+
fi
|
|
186
|
+
|
|
181
187
|
echo -e "${GREEN}✅ Pages secrets deployed to $pages_env${NC}"
|
|
182
188
|
}
|
|
183
189
|
|