@striae-org/striae 4.1.0 → 4.2.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/.env.example +8 -0
- package/LICENSE +1 -1
- 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 +463 -8
- package/app/components/actions/confirm-export.ts +9 -2
- package/app/components/actions/image-manage.ts +77 -44
- package/app/components/audit/user-audit-viewer.tsx +19 -8
- package/app/components/audit/user-audit.module.css +21 -0
- package/app/components/audit/viewer/audit-entries-list.tsx +12 -2
- package/app/components/audit/viewer/audit-filters-panel.tsx +1 -0
- package/app/components/audit/viewer/audit-viewer-utils.ts +2 -0
- package/app/components/audit/viewer/use-audit-viewer-data.ts +24 -1
- package/app/components/audit/viewer/use-audit-viewer-export.ts +1 -1
- 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 +14 -16
- package/app/components/canvas/confirmation/confirmation.module.css +1 -0
- package/app/components/canvas/confirmation/confirmation.tsx +12 -14
- package/app/components/colors/colors.module.css +4 -3
- 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 +402 -0
- package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +1 -0
- package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +15 -16
- package/app/components/sidebar/case-export/case-export.module.css +1 -0
- package/app/components/sidebar/case-export/case-export.tsx +8 -46
- package/app/components/sidebar/case-import/case-import.module.css +23 -0
- package/app/components/sidebar/case-import/case-import.tsx +64 -16
- 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 +68 -588
- package/app/components/sidebar/cases/cases-modal.module.css +1 -0
- package/app/components/sidebar/cases/cases-modal.tsx +82 -43
- package/app/components/sidebar/cases/cases.module.css +82 -21
- package/app/components/sidebar/files/files-modal.module.css +1 -0
- package/app/components/sidebar/files/files-modal.tsx +49 -52
- package/app/components/sidebar/notes/addl-notes-modal.tsx +82 -0
- package/app/components/sidebar/notes/{notes-sidebar.tsx → notes-editor-form.tsx} +187 -138
- package/app/components/sidebar/notes/notes-editor-modal.module.css +49 -0
- package/app/components/sidebar/notes/notes-editor-modal.tsx +64 -0
- package/app/components/sidebar/notes/notes.module.css +170 -1
- package/app/components/sidebar/sidebar-container.tsx +16 -28
- package/app/components/sidebar/sidebar.module.css +5 -69
- package/app/components/sidebar/sidebar.tsx +27 -125
- package/app/components/sidebar/upload/image-upload-zone.module.css +13 -13
- package/app/components/user/inactivity-warning.module.css +1 -0
- package/app/components/user/inactivity-warning.tsx +15 -2
- package/app/components/user/manage-profile.tsx +23 -10
- package/app/{tailwind.css → global.css} +1 -3
- package/app/hooks/useOverlayDismiss.ts +54 -4
- package/app/root.tsx +1 -1
- package/app/routes/auth/login.tsx +785 -774
- package/app/routes/striae/striae.module.css +10 -3
- package/app/routes/striae/striae.tsx +475 -30
- package/app/services/audit/audit.service.ts +173 -27
- package/app/services/audit/builders/audit-event-builders-case-file.ts +43 -0
- package/app/services/audit/builders/audit-event-builders-workflow.ts +2 -0
- package/app/services/audit/builders/index.ts +1 -0
- package/app/types/audit.ts +4 -1
- package/app/types/case.ts +29 -0
- package/app/types/import.ts +3 -0
- package/app/utils/data/confirmation-summary/summary-core.ts +279 -0
- package/app/utils/data/data-operations.ts +17 -861
- package/app/utils/data/index.ts +11 -1
- package/app/utils/data/operations/batch-operations.ts +113 -0
- package/app/utils/data/operations/case-operations.ts +168 -0
- package/app/utils/data/operations/confirmation-summary-operations.ts +301 -0
- package/app/utils/data/operations/file-annotation-operations.ts +196 -0
- package/app/utils/data/operations/index.ts +7 -0
- package/app/utils/data/operations/signing-operations.ts +225 -0
- package/app/utils/data/operations/types.ts +42 -0
- package/app/utils/data/operations/validation-operations.ts +48 -0
- package/app/utils/data/permissions.ts +16 -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 +426 -22
- package/functions/api/_shared/firebase-auth.ts +2 -7
- package/functions/api/image/[[path]].ts +20 -23
- package/functions/api/pdf/[[path]].ts +27 -8
- package/package.json +7 -12
- package/scripts/deploy-primershear-emails.sh +2 -1
- package/worker-configuration.d.ts +3 -3
- package/workers/audit-worker/package.json +1 -1
- package/workers/audit-worker/worker-configuration.d.ts +7448 -11323
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/package.json +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/package.json +1 -1
- package/workers/image-worker/src/image-worker.example.ts +16 -5
- package/workers/image-worker/worker-configuration.d.ts +7447 -11322
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/keys-worker/package.json +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/package.json +1 -1
- package/workers/pdf-worker/src/formats/format-striae.ts +9 -14
- package/workers/pdf-worker/src/pdf-worker.example.ts +37 -58
- package/workers/pdf-worker/src/report-types.ts +3 -3
- package/workers/pdf-worker/worker-configuration.d.ts +7448 -11323
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/package.json +1 -1
- package/workers/user-worker/src/user-worker.example.ts +17 -0
- 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/NOTICE +0 -13
- package/app/components/sidebar/notes/notes-modal.tsx +0 -53
- package/postcss.config.js +0 -6
- package/public/.well-known/keybase.txt +0 -56
- package/tailwind.config.ts +0 -22
|
@@ -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,118 @@ 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
|
+
const verificationPublicKeyPem = input.verificationPublicKeyPem;
|
|
678
|
+
|
|
679
|
+
if (!manifestData) {
|
|
680
|
+
return {
|
|
681
|
+
isValid: false,
|
|
682
|
+
signatureResult: {
|
|
683
|
+
isValid: false,
|
|
684
|
+
error: 'Forensic manifest structure is invalid'
|
|
685
|
+
},
|
|
686
|
+
integrityResult: {
|
|
687
|
+
isValid: false,
|
|
688
|
+
dataValid: false,
|
|
689
|
+
imageValidation: {},
|
|
690
|
+
manifestValid: false,
|
|
691
|
+
errors: ['Forensic manifest structure is invalid'],
|
|
692
|
+
summary: 'Manifest validation failed'
|
|
693
|
+
},
|
|
694
|
+
bundledAuditVerification: null
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (!verificationPublicKeyPem) {
|
|
699
|
+
return {
|
|
700
|
+
isValid: false,
|
|
701
|
+
signatureResult: {
|
|
702
|
+
isValid: false,
|
|
703
|
+
error: 'Missing verification public key'
|
|
704
|
+
},
|
|
705
|
+
integrityResult: {
|
|
706
|
+
isValid: false,
|
|
707
|
+
dataValid: false,
|
|
708
|
+
imageValidation: {},
|
|
709
|
+
manifestValid: false,
|
|
710
|
+
errors: ['Missing verification public key'],
|
|
711
|
+
summary: 'Manifest validation failed'
|
|
712
|
+
},
|
|
713
|
+
bundledAuditVerification: null
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const signatureResult = await verifyForensicManifestSignature(
|
|
718
|
+
input.forensicManifest,
|
|
719
|
+
verificationPublicKeyPem
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
const integrityResult = await validateCaseIntegritySecure(
|
|
723
|
+
input.cleanedContent,
|
|
724
|
+
input.imageFiles,
|
|
725
|
+
manifestData
|
|
352
726
|
);
|
|
727
|
+
|
|
728
|
+
const bundledAuditVerification = input.bundledAuditFiles
|
|
729
|
+
? await verifyBundledAuditExport(
|
|
730
|
+
{
|
|
731
|
+
file: (path: string) => {
|
|
732
|
+
const content = path === 'audit/case-audit-trail.json'
|
|
733
|
+
? input.bundledAuditFiles?.auditTrailContent
|
|
734
|
+
: path === 'audit/case-audit-signature.json'
|
|
735
|
+
? input.bundledAuditFiles?.auditSignatureContent
|
|
736
|
+
: undefined;
|
|
737
|
+
|
|
738
|
+
if (content === undefined) {
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
return {
|
|
743
|
+
async: async () => content,
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
},
|
|
747
|
+
verificationPublicKeyPem
|
|
748
|
+
)
|
|
749
|
+
: null;
|
|
750
|
+
|
|
751
|
+
return {
|
|
752
|
+
isValid: signatureResult.isValid && integrityResult.isValid && !bundledAuditVerification,
|
|
753
|
+
signatureResult,
|
|
754
|
+
integrityResult,
|
|
755
|
+
bundledAuditVerification
|
|
756
|
+
};
|
|
353
757
|
}
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import firebaseConfig from '../../../app/config/firebase';
|
|
2
|
-
|
|
3
1
|
interface FirebaseJwtHeader {
|
|
4
2
|
alg?: string;
|
|
5
3
|
kid?: string;
|
|
@@ -31,8 +29,6 @@ const GOOGLE_SECURETOKEN_JWKS_URL =
|
|
|
31
29
|
'https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com';
|
|
32
30
|
const DEFAULT_JWKS_CACHE_SECONDS = 300;
|
|
33
31
|
const CLOCK_SKEW_SECONDS = 300;
|
|
34
|
-
const FALLBACK_PROJECT_ID =
|
|
35
|
-
typeof firebaseConfig.projectId === 'string' ? firebaseConfig.projectId.trim() : '';
|
|
36
32
|
|
|
37
33
|
const textEncoder = new TextEncoder();
|
|
38
34
|
const textDecoder = new TextDecoder();
|
|
@@ -156,12 +152,11 @@ async function verifyTokenSignature(
|
|
|
156
152
|
|
|
157
153
|
function validateTokenClaims(payload: FirebaseJwtPayload, env: Env): boolean {
|
|
158
154
|
const configuredProjectId = typeof env.PROJECT_ID === 'string' ? env.PROJECT_ID.trim() : '';
|
|
159
|
-
|
|
160
|
-
if (allowedProjectIds.size === 0) {
|
|
155
|
+
if (configuredProjectId.length === 0) {
|
|
161
156
|
return false;
|
|
162
157
|
}
|
|
163
158
|
|
|
164
|
-
if (typeof payload.aud !== 'string' ||
|
|
159
|
+
if (typeof payload.aud !== 'string' || payload.aud !== configuredProjectId) {
|
|
165
160
|
return false;
|
|
166
161
|
}
|
|
167
162
|
|
|
@@ -30,44 +30,37 @@ function normalizeWorkerBaseUrl(workerDomain: string): string {
|
|
|
30
30
|
return `https://${trimmedDomain}`;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
type ProxyPathResult =
|
|
34
|
+
| { ok: true; path: string }
|
|
35
|
+
| { ok: false; reason: 'not-found' | 'bad-encoding' };
|
|
36
|
+
|
|
37
|
+
function extractProxyPath(url: URL): ProxyPathResult {
|
|
34
38
|
const routePrefix = '/api/image';
|
|
35
39
|
if (!url.pathname.startsWith(routePrefix)) {
|
|
36
|
-
return
|
|
40
|
+
return { ok: false, reason: 'not-found' };
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
const remainder = url.pathname.slice(routePrefix.length);
|
|
40
44
|
if (remainder.length === 0) {
|
|
41
|
-
return '/';
|
|
45
|
+
return { ok: true, path: '/' };
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
const normalizedRemainder = remainder.startsWith('/') ? remainder : `/${remainder}`;
|
|
45
49
|
const encodedPath = normalizedRemainder.slice(1);
|
|
50
|
+
if (encodedPath.length === 0) {
|
|
51
|
+
return { ok: true, path: normalizedRemainder };
|
|
52
|
+
}
|
|
46
53
|
|
|
47
54
|
try {
|
|
48
55
|
const decodedPath = decodeURIComponent(encodedPath);
|
|
49
|
-
|
|
50
|
-
return decodedPath.startsWith('/') ? decodedPath : `/${decodedPath}`;
|
|
51
|
-
}
|
|
56
|
+
return { ok: true, path: decodedPath.startsWith('/') ? decodedPath : `/${decodedPath}` };
|
|
52
57
|
} catch {
|
|
53
|
-
|
|
58
|
+
return { ok: false, reason: 'bad-encoding' };
|
|
54
59
|
}
|
|
55
|
-
|
|
56
|
-
return normalizedRemainder;
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
function resolveImageWorkerToken(env: Env): string {
|
|
60
|
-
|
|
61
|
-
if (imageToken.length > 0) {
|
|
62
|
-
return imageToken;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const apiToken = typeof env.API_TOKEN === 'string' ? env.API_TOKEN.trim() : '';
|
|
66
|
-
if (apiToken.length > 0) {
|
|
67
|
-
return apiToken;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
return '';
|
|
63
|
+
return typeof env.IMAGES_API_TOKEN === 'string' ? env.IMAGES_API_TOKEN.trim() : '';
|
|
71
64
|
}
|
|
72
65
|
|
|
73
66
|
export const onRequest = async ({ request, env }: ImageProxyContext): Promise<Response> => {
|
|
@@ -91,11 +84,15 @@ export const onRequest = async ({ request, env }: ImageProxyContext): Promise<Re
|
|
|
91
84
|
}
|
|
92
85
|
|
|
93
86
|
const requestUrl = new URL(request.url);
|
|
94
|
-
const
|
|
95
|
-
if (!
|
|
96
|
-
return
|
|
87
|
+
const proxyPathResult = extractProxyPath(requestUrl);
|
|
88
|
+
if (!proxyPathResult.ok) {
|
|
89
|
+
return proxyPathResult.reason === 'bad-encoding'
|
|
90
|
+
? textResponse('Bad Request: malformed image path encoding', 400)
|
|
91
|
+
: textResponse('Not Found', 404);
|
|
97
92
|
}
|
|
98
93
|
|
|
94
|
+
const proxyPath = proxyPathResult.path;
|
|
95
|
+
|
|
99
96
|
const imageWorkerToken = resolveImageWorkerToken(env);
|
|
100
97
|
if (!env.IMAGES_WORKER_DOMAIN || !imageWorkerToken) {
|
|
101
98
|
return textResponse('Image service not configured', 502);
|
|
@@ -9,6 +9,10 @@ const SUPPORTED_METHODS = new Set(['POST', 'OPTIONS']);
|
|
|
9
9
|
const PRIMERSHEAR_FORMAT = 'primershear';
|
|
10
10
|
const DEFAULT_FORMAT = 'striae';
|
|
11
11
|
|
|
12
|
+
interface PdfProxyRequestBody {
|
|
13
|
+
data: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
12
16
|
function textResponse(message: string, status: number): Response {
|
|
13
17
|
return new Response(message, {
|
|
14
18
|
status,
|
|
@@ -48,6 +52,21 @@ function resolveReportFormat(email: string | null, primershearEmails: string): s
|
|
|
48
52
|
return allowed.includes(email.toLowerCase()) ? PRIMERSHEAR_FORMAT : DEFAULT_FORMAT;
|
|
49
53
|
}
|
|
50
54
|
|
|
55
|
+
function parsePdfProxyRequestBody(payload: unknown): PdfProxyRequestBody | null {
|
|
56
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const record = payload as Record<string, unknown>;
|
|
61
|
+
if (!record.data || typeof record.data !== 'object' || Array.isArray(record.data)) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
data: record.data as Record<string, unknown>
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
51
70
|
export const onRequest = async ({ request, env }: PdfProxyContext): Promise<Response> => {
|
|
52
71
|
if (!SUPPORTED_METHODS.has(request.method)) {
|
|
53
72
|
return textResponse('Method not allowed', 405);
|
|
@@ -103,15 +122,15 @@ export const onRequest = async ({ request, env }: PdfProxyContext): Promise<Resp
|
|
|
103
122
|
|
|
104
123
|
let upstreamBody: BodyInit;
|
|
105
124
|
try {
|
|
106
|
-
const payload = await request.json()
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
payload.reportFormat = reportFormat;
|
|
110
|
-
} else {
|
|
111
|
-
// Legacy flat payload shape
|
|
112
|
-
payload.reportFormat = reportFormat;
|
|
125
|
+
const payload = parsePdfProxyRequestBody(await request.json());
|
|
126
|
+
if (!payload) {
|
|
127
|
+
return textResponse('Invalid PDF request body', 400);
|
|
113
128
|
}
|
|
114
|
-
|
|
129
|
+
|
|
130
|
+
upstreamBody = JSON.stringify({
|
|
131
|
+
data: payload.data,
|
|
132
|
+
reportFormat
|
|
133
|
+
});
|
|
115
134
|
upstreamHeaders.set('Content-Type', 'application/json');
|
|
116
135
|
} catch {
|
|
117
136
|
return textResponse('Invalid request body', 400);
|