@striae-org/striae 4.2.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/LICENSE +1 -1
- package/app/components/actions/case-manage.ts +50 -17
- package/app/components/audit/viewer/audit-entries-list.tsx +5 -2
- package/app/components/audit/viewer/use-audit-viewer-data.ts +6 -3
- package/app/components/audit/viewer/use-audit-viewer-export.ts +1 -1
- package/app/components/canvas/confirmation/confirmation.tsx +6 -2
- package/app/components/colors/colors.module.css +4 -3
- package/app/components/navbar/navbar.tsx +34 -9
- package/app/components/sidebar/cases/case-sidebar.tsx +44 -70
- package/app/components/sidebar/cases/cases-modal.tsx +76 -35
- package/app/components/sidebar/cases/cases.module.css +20 -0
- package/app/components/sidebar/files/files-modal.tsx +37 -39
- package/app/components/sidebar/notes/addl-notes-modal.tsx +82 -0
- package/app/components/sidebar/notes/{notes-sidebar.tsx → notes-editor-form.tsx} +37 -74
- package/app/components/sidebar/notes/notes-editor-modal.tsx +5 -7
- package/app/components/sidebar/notes/notes.module.css +27 -11
- package/app/components/sidebar/sidebar-container.tsx +1 -0
- package/app/components/sidebar/sidebar.tsx +3 -0
- package/app/{tailwind.css → global.css} +1 -3
- package/app/hooks/useOverlayDismiss.ts +6 -4
- package/app/root.tsx +1 -1
- package/app/routes/striae/striae.tsx +6 -0
- package/app/services/audit/audit.service.ts +2 -2
- package/app/services/audit/builders/audit-event-builders-case-file.ts +1 -1
- package/app/types/audit.ts +1 -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/forensics/export-verification.ts +40 -111
- 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 +5 -10
- package/scripts/deploy-primershear-emails.sh +1 -1
- package/worker-configuration.d.ts +2 -2
- 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/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/wrangler.jsonc.example +1 -1
- package/workers/keys-worker/package.json +1 -1
- 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 +1 -7
- 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/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/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
- package/NOTICE +0 -13
- package/app/components/sidebar/notes/notes-modal.tsx +0 -52
- package/postcss.config.js +0 -6
- package/tailwind.config.ts +0 -22
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type { User } from 'firebase/auth';
|
|
2
|
+
import type { AnnotationData } from '~/types';
|
|
3
|
+
|
|
4
|
+
import { fetchDataApi } from '../../api';
|
|
5
|
+
import { canAccessCase, canModifyCase, validateUserSession } from '../permissions';
|
|
6
|
+
import { removeFileConfirmationSummary, upsertFileConfirmationSummary } from './confirmation-summary-operations';
|
|
7
|
+
import type { DataOperationOptions } from './types';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Get file annotation data from R2 storage.
|
|
11
|
+
*/
|
|
12
|
+
export const getFileAnnotations = async (
|
|
13
|
+
user: User,
|
|
14
|
+
caseNumber: string,
|
|
15
|
+
fileId: string
|
|
16
|
+
): Promise<AnnotationData | null> => {
|
|
17
|
+
try {
|
|
18
|
+
const sessionValidation = await validateUserSession(user);
|
|
19
|
+
if (!sessionValidation.valid) {
|
|
20
|
+
throw new Error(`Session validation failed: ${sessionValidation.reason}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const accessCheck = await canAccessCase(user, caseNumber);
|
|
24
|
+
if (!accessCheck.allowed) {
|
|
25
|
+
throw new Error(`Access denied: ${accessCheck.reason}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!fileId || typeof fileId !== 'string') {
|
|
29
|
+
throw new Error('Invalid file ID provided');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const response = await fetchDataApi(
|
|
33
|
+
user,
|
|
34
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
|
|
35
|
+
{
|
|
36
|
+
method: 'GET'
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
if (response.status === 404) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
throw new Error(`Failed to fetch file annotations: ${response.status} ${response.statusText}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return await response.json() as AnnotationData;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
console.error(`Error fetching annotations for ${caseNumber}/${fileId}:`, error);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Save file annotation data to R2 storage.
|
|
57
|
+
*/
|
|
58
|
+
export const saveFileAnnotations = async (
|
|
59
|
+
user: User,
|
|
60
|
+
caseNumber: string,
|
|
61
|
+
fileId: string,
|
|
62
|
+
annotationData: AnnotationData,
|
|
63
|
+
options: DataOperationOptions = {}
|
|
64
|
+
): Promise<void> => {
|
|
65
|
+
try {
|
|
66
|
+
const sessionValidation = await validateUserSession(user);
|
|
67
|
+
if (!sessionValidation.valid) {
|
|
68
|
+
throw new Error(`Session validation failed: ${sessionValidation.reason}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (options.skipValidation !== true) {
|
|
72
|
+
const modifyCheck = await canModifyCase(user, caseNumber);
|
|
73
|
+
if (!modifyCheck.allowed) {
|
|
74
|
+
throw new Error(`Modification denied: ${modifyCheck.reason}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!fileId || typeof fileId !== 'string') {
|
|
79
|
+
throw new Error('Invalid file ID provided');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!annotationData || typeof annotationData !== 'object') {
|
|
83
|
+
throw new Error('Invalid annotation data provided');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Enforce immutability once confirmation data exists on an image.
|
|
87
|
+
const existingResponse = await fetchDataApi(
|
|
88
|
+
user,
|
|
89
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
|
|
90
|
+
{
|
|
91
|
+
method: 'GET'
|
|
92
|
+
}
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
if (existingResponse.ok) {
|
|
96
|
+
const existingAnnotations = await existingResponse.json() as AnnotationData;
|
|
97
|
+
if (existingAnnotations?.confirmationData) {
|
|
98
|
+
throw new Error('Cannot modify annotations for a confirmed image');
|
|
99
|
+
}
|
|
100
|
+
} else if (existingResponse.status !== 404) {
|
|
101
|
+
throw new Error(`Failed to verify existing annotations: ${existingResponse.status} ${existingResponse.statusText}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const dataToSave = {
|
|
105
|
+
...annotationData,
|
|
106
|
+
updatedAt: new Date().toISOString()
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const response = await fetchDataApi(
|
|
110
|
+
user,
|
|
111
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
|
|
112
|
+
{
|
|
113
|
+
method: 'PUT',
|
|
114
|
+
headers: {
|
|
115
|
+
'Content-Type': 'application/json'
|
|
116
|
+
},
|
|
117
|
+
body: JSON.stringify(dataToSave)
|
|
118
|
+
}
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
if (!response.ok) {
|
|
122
|
+
throw new Error(`Failed to save file annotations: ${response.status} ${response.statusText}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
await upsertFileConfirmationSummary(user, caseNumber, fileId, dataToSave);
|
|
127
|
+
} catch (summaryError) {
|
|
128
|
+
console.warn(`Failed to update confirmation summary for ${caseNumber}/${fileId}:`, summaryError);
|
|
129
|
+
}
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.error(`Error saving annotations for ${caseNumber}/${fileId}:`, error);
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Delete file annotation data from R2 storage.
|
|
138
|
+
*/
|
|
139
|
+
export const deleteFileAnnotations = async (
|
|
140
|
+
user: User,
|
|
141
|
+
caseNumber: string,
|
|
142
|
+
fileId: string,
|
|
143
|
+
options: { skipValidation?: boolean } = {}
|
|
144
|
+
): Promise<void> => {
|
|
145
|
+
try {
|
|
146
|
+
const sessionValidation = await validateUserSession(user);
|
|
147
|
+
if (!sessionValidation.valid) {
|
|
148
|
+
throw new Error(`Session validation failed: ${sessionValidation.reason}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (options.skipValidation !== true) {
|
|
152
|
+
const modifyCheck = await canModifyCase(user, caseNumber);
|
|
153
|
+
if (!modifyCheck.allowed) {
|
|
154
|
+
throw new Error(`Delete denied: ${modifyCheck.reason}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const response = await fetchDataApi(
|
|
159
|
+
user,
|
|
160
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
|
|
161
|
+
{
|
|
162
|
+
method: 'DELETE'
|
|
163
|
+
}
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
if (!response.ok && response.status !== 404) {
|
|
167
|
+
throw new Error(`Failed to delete file annotations: ${response.status} ${response.statusText}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
await removeFileConfirmationSummary(user, caseNumber, fileId);
|
|
172
|
+
} catch (summaryError) {
|
|
173
|
+
console.warn(`Failed to update confirmation summary after delete for ${caseNumber}/${fileId}:`, summaryError);
|
|
174
|
+
}
|
|
175
|
+
} catch (error) {
|
|
176
|
+
console.error(`Error deleting annotations for ${caseNumber}/${fileId}:`, error);
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Check if a file has annotations.
|
|
183
|
+
*/
|
|
184
|
+
export const fileHasAnnotations = async (
|
|
185
|
+
user: User,
|
|
186
|
+
caseNumber: string,
|
|
187
|
+
fileId: string
|
|
188
|
+
): Promise<boolean> => {
|
|
189
|
+
try {
|
|
190
|
+
const annotations = await getFileAnnotations(user, caseNumber, fileId);
|
|
191
|
+
return annotations !== null;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
console.error(`Error checking annotations for ${caseNumber}/${fileId}:`, error);
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from './types';
|
|
2
|
+
export * from './confirmation-summary-operations';
|
|
3
|
+
export * from './case-operations';
|
|
4
|
+
export * from './file-annotation-operations';
|
|
5
|
+
export * from './batch-operations';
|
|
6
|
+
export * from './validation-operations';
|
|
7
|
+
export * from './signing-operations';
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type { User } from 'firebase/auth';
|
|
2
|
+
import type { ConfirmationImportData } from '~/types';
|
|
3
|
+
|
|
4
|
+
import { fetchDataApi } from '../../api';
|
|
5
|
+
import {
|
|
6
|
+
AUDIT_EXPORT_SIGNATURE_VERSION,
|
|
7
|
+
type AuditExportSigningPayload,
|
|
8
|
+
isValidAuditExportSigningPayload
|
|
9
|
+
} from '../../forensics/audit-export-signature';
|
|
10
|
+
import { CONFIRMATION_SIGNATURE_VERSION } from '../../forensics/confirmation-signature';
|
|
11
|
+
import {
|
|
12
|
+
type ForensicManifestData,
|
|
13
|
+
type ForensicManifestSignature,
|
|
14
|
+
FORENSIC_MANIFEST_VERSION
|
|
15
|
+
} from '../../forensics/SHA256';
|
|
16
|
+
import { canAccessCase, validateUserSession } from '../permissions';
|
|
17
|
+
import type {
|
|
18
|
+
AuditExportSigningResponse,
|
|
19
|
+
ConfirmationSigningResponse,
|
|
20
|
+
ManifestSigningResponse
|
|
21
|
+
} from './types';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Request a server-side signature for a forensic manifest.
|
|
25
|
+
*/
|
|
26
|
+
export const signForensicManifest = async (
|
|
27
|
+
user: User,
|
|
28
|
+
caseNumber: string,
|
|
29
|
+
manifest: ForensicManifestData
|
|
30
|
+
): Promise<ManifestSigningResponse> => {
|
|
31
|
+
try {
|
|
32
|
+
const sessionValidation = await validateUserSession(user);
|
|
33
|
+
if (!sessionValidation.valid) {
|
|
34
|
+
throw new Error(`Session validation failed: ${sessionValidation.reason}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const accessCheck = await canAccessCase(user, caseNumber);
|
|
38
|
+
if (!accessCheck.allowed) {
|
|
39
|
+
throw new Error(`Manifest signing denied: ${accessCheck.reason}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const response = await fetchDataApi(user, '/api/forensic/sign-manifest', {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: {
|
|
45
|
+
'Content-Type': 'application/json'
|
|
46
|
+
},
|
|
47
|
+
body: JSON.stringify({
|
|
48
|
+
userId: user.uid,
|
|
49
|
+
caseNumber,
|
|
50
|
+
manifest
|
|
51
|
+
})
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const responseData = await response.json().catch(() => null) as {
|
|
55
|
+
success?: boolean;
|
|
56
|
+
error?: string;
|
|
57
|
+
manifestVersion?: string;
|
|
58
|
+
signature?: ForensicManifestSignature;
|
|
59
|
+
} | null;
|
|
60
|
+
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
responseData?.error ||
|
|
64
|
+
`Failed to sign forensic manifest: ${response.status} ${response.statusText}`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!responseData?.success || !responseData.signature || !responseData.manifestVersion) {
|
|
69
|
+
throw new Error('Invalid manifest signing response from data worker');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (responseData.manifestVersion !== FORENSIC_MANIFEST_VERSION) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Unexpected manifest version from signer: ${responseData.manifestVersion}`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
manifestVersion: responseData.manifestVersion,
|
|
80
|
+
signature: responseData.signature
|
|
81
|
+
};
|
|
82
|
+
} catch (error) {
|
|
83
|
+
console.error(`Error signing forensic manifest for ${caseNumber}:`, error);
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Request a server-side signature for confirmation export data.
|
|
90
|
+
*/
|
|
91
|
+
export const signConfirmationData = async (
|
|
92
|
+
user: User,
|
|
93
|
+
caseNumber: string,
|
|
94
|
+
confirmationData: ConfirmationImportData
|
|
95
|
+
): Promise<ConfirmationSigningResponse> => {
|
|
96
|
+
try {
|
|
97
|
+
const sessionValidation = await validateUserSession(user);
|
|
98
|
+
if (!sessionValidation.valid) {
|
|
99
|
+
throw new Error(`Session validation failed: ${sessionValidation.reason}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const accessCheck = await canAccessCase(user, caseNumber);
|
|
103
|
+
if (!accessCheck.allowed) {
|
|
104
|
+
throw new Error(`Confirmation signing denied: ${accessCheck.reason}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const response = await fetchDataApi(user, '/api/forensic/sign-confirmation', {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: {
|
|
110
|
+
'Content-Type': 'application/json'
|
|
111
|
+
},
|
|
112
|
+
body: JSON.stringify({
|
|
113
|
+
userId: user.uid,
|
|
114
|
+
caseNumber,
|
|
115
|
+
confirmationData,
|
|
116
|
+
signatureVersion: CONFIRMATION_SIGNATURE_VERSION
|
|
117
|
+
})
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const responseData = await response.json().catch(() => null) as {
|
|
121
|
+
success?: boolean;
|
|
122
|
+
error?: string;
|
|
123
|
+
signatureVersion?: string;
|
|
124
|
+
signature?: ForensicManifestSignature;
|
|
125
|
+
} | null;
|
|
126
|
+
|
|
127
|
+
if (!response.ok) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
responseData?.error ||
|
|
130
|
+
`Failed to sign confirmation data: ${response.status} ${response.statusText}`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!responseData?.success || !responseData.signature || !responseData.signatureVersion) {
|
|
135
|
+
throw new Error('Invalid confirmation signing response from data worker');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (responseData.signatureVersion !== CONFIRMATION_SIGNATURE_VERSION) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`Unexpected confirmation signature version from signer: ${responseData.signatureVersion}`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
signatureVersion: responseData.signatureVersion,
|
|
146
|
+
signature: responseData.signature
|
|
147
|
+
};
|
|
148
|
+
} catch (error) {
|
|
149
|
+
console.error(`Error signing confirmation data for ${caseNumber}:`, error);
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Request a server-side signature for audit export metadata.
|
|
156
|
+
*/
|
|
157
|
+
export const signAuditExportData = async (
|
|
158
|
+
user: User,
|
|
159
|
+
auditExport: AuditExportSigningPayload,
|
|
160
|
+
options: { caseNumber?: string } = {}
|
|
161
|
+
): Promise<AuditExportSigningResponse> => {
|
|
162
|
+
try {
|
|
163
|
+
const sessionValidation = await validateUserSession(user);
|
|
164
|
+
if (!sessionValidation.valid) {
|
|
165
|
+
throw new Error(`Session validation failed: ${sessionValidation.reason}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!isValidAuditExportSigningPayload(auditExport)) {
|
|
169
|
+
throw new Error('Invalid audit export payload for signing');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const caseNumber = options.caseNumber;
|
|
173
|
+
if (caseNumber) {
|
|
174
|
+
const accessCheck = await canAccessCase(user, caseNumber);
|
|
175
|
+
if (!accessCheck.allowed) {
|
|
176
|
+
throw new Error(`Audit export signing denied: ${accessCheck.reason}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const response = await fetchDataApi(user, '/api/forensic/sign-audit-export', {
|
|
181
|
+
method: 'POST',
|
|
182
|
+
headers: {
|
|
183
|
+
'Content-Type': 'application/json'
|
|
184
|
+
},
|
|
185
|
+
body: JSON.stringify({
|
|
186
|
+
userId: user.uid,
|
|
187
|
+
caseNumber,
|
|
188
|
+
auditExport,
|
|
189
|
+
signatureVersion: AUDIT_EXPORT_SIGNATURE_VERSION
|
|
190
|
+
})
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const responseData = await response.json().catch(() => null) as {
|
|
194
|
+
success?: boolean;
|
|
195
|
+
error?: string;
|
|
196
|
+
signatureVersion?: string;
|
|
197
|
+
signature?: ForensicManifestSignature;
|
|
198
|
+
} | null;
|
|
199
|
+
|
|
200
|
+
if (!response.ok) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
responseData?.error ||
|
|
203
|
+
`Failed to sign audit export data: ${response.status} ${response.statusText}`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!responseData?.success || !responseData.signature || !responseData.signatureVersion) {
|
|
208
|
+
throw new Error('Invalid audit export signing response from data worker');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (responseData.signatureVersion !== AUDIT_EXPORT_SIGNATURE_VERSION) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`Unexpected audit export signature version from signer: ${responseData.signatureVersion}`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
signatureVersion: responseData.signatureVersion,
|
|
219
|
+
signature: responseData.signature
|
|
220
|
+
};
|
|
221
|
+
} catch (error) {
|
|
222
|
+
console.error('Error signing audit export data:', error);
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { User } from 'firebase/auth';
|
|
2
|
+
import type { ForensicManifestSignature } from '~/utils/forensics/SHA256';
|
|
3
|
+
|
|
4
|
+
import type { AnnotationData } from '~/types';
|
|
5
|
+
|
|
6
|
+
export interface DataAccessResult {
|
|
7
|
+
allowed: boolean;
|
|
8
|
+
reason?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface FileUpdate {
|
|
12
|
+
fileId: string;
|
|
13
|
+
annotations: AnnotationData;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface BatchUpdateResult {
|
|
17
|
+
successful: string[];
|
|
18
|
+
failed: { fileId: string; error: string }[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DataOperationOptions {
|
|
22
|
+
includeTimestamp?: boolean;
|
|
23
|
+
retryCount?: number;
|
|
24
|
+
skipValidation?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ManifestSigningResponse {
|
|
28
|
+
manifestVersion: string;
|
|
29
|
+
signature: ForensicManifestSignature;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ConfirmationSigningResponse {
|
|
33
|
+
signatureVersion: string;
|
|
34
|
+
signature: ForensicManifestSignature;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AuditExportSigningResponse {
|
|
38
|
+
signatureVersion: string;
|
|
39
|
+
signature: ForensicManifestSignature;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type DataOperation<T> = (user: User, ...args: unknown[]) => Promise<T>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { User } from 'firebase/auth';
|
|
2
|
+
|
|
3
|
+
import { canAccessCase, validateUserSession } from '../permissions';
|
|
4
|
+
import type { DataAccessResult, DataOperation } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Validate data access permissions for a user and case.
|
|
8
|
+
*/
|
|
9
|
+
export const validateDataAccess = async (
|
|
10
|
+
user: User,
|
|
11
|
+
caseNumber: string
|
|
12
|
+
): Promise<DataAccessResult> => {
|
|
13
|
+
try {
|
|
14
|
+
const sessionValidation = await validateUserSession(user);
|
|
15
|
+
if (!sessionValidation.valid) {
|
|
16
|
+
return { allowed: false, reason: sessionValidation.reason };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const accessCheck = await canAccessCase(user, caseNumber);
|
|
20
|
+
if (!accessCheck.allowed) {
|
|
21
|
+
return { allowed: false, reason: accessCheck.reason };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return { allowed: true };
|
|
25
|
+
} catch (error) {
|
|
26
|
+
console.error('Error validating data access:', error);
|
|
27
|
+
return { allowed: false, reason: 'Access validation failed' };
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Higher-order function for consistent data operation patterns.
|
|
33
|
+
*/
|
|
34
|
+
export const withDataOperation = <T>(
|
|
35
|
+
operation: DataOperation<T>
|
|
36
|
+
) => async (user: User, ...args: unknown[]): Promise<T> => {
|
|
37
|
+
try {
|
|
38
|
+
const sessionValidation = await validateUserSession(user);
|
|
39
|
+
if (!sessionValidation.valid) {
|
|
40
|
+
throw new Error(`Operation failed: ${sessionValidation.reason}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return await operation(user, ...args);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.error('Data operation failed:', error);
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
@@ -674,6 +674,7 @@ export async function verifyCasePackageIntegrity(
|
|
|
674
674
|
input: CasePackageIntegrityInput
|
|
675
675
|
): Promise<CasePackageIntegrityResult> {
|
|
676
676
|
const manifestData = extractForensicManifestData(input.forensicManifest);
|
|
677
|
+
const verificationPublicKeyPem = input.verificationPublicKeyPem;
|
|
677
678
|
|
|
678
679
|
if (!manifestData) {
|
|
679
680
|
return {
|
|
@@ -694,9 +695,28 @@ export async function verifyCasePackageIntegrity(
|
|
|
694
695
|
};
|
|
695
696
|
}
|
|
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
|
+
|
|
697
717
|
const signatureResult = await verifyForensicManifestSignature(
|
|
698
718
|
input.forensicManifest,
|
|
699
|
-
|
|
719
|
+
verificationPublicKeyPem
|
|
700
720
|
);
|
|
701
721
|
|
|
702
722
|
const integrityResult = await validateCaseIntegritySecure(
|
|
@@ -706,117 +726,26 @@ export async function verifyCasePackageIntegrity(
|
|
|
706
726
|
);
|
|
707
727
|
|
|
708
728
|
const bundledAuditVerification = input.bundledAuditFiles
|
|
709
|
-
? await (
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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
|
-
);
|
|
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
|
+
};
|
|
798
745
|
}
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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
|
-
})()
|
|
746
|
+
},
|
|
747
|
+
verificationPublicKeyPem
|
|
748
|
+
)
|
|
820
749
|
: null;
|
|
821
750
|
|
|
822
751
|
return {
|