@striae-org/striae 4.3.4 → 5.0.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 +4 -0
- package/app/components/actions/case-export/download-handlers.ts +60 -4
- package/app/components/actions/case-import/confirmation-import.ts +50 -7
- package/app/components/actions/case-import/confirmation-package.ts +99 -22
- package/app/components/actions/case-import/orchestrator.ts +116 -13
- package/app/components/actions/case-import/validation.ts +171 -7
- package/app/components/actions/case-import/zip-processing.ts +224 -127
- package/app/components/actions/case-manage.ts +64 -4
- package/app/components/actions/confirm-export.ts +32 -3
- package/app/components/navbar/navbar.module.css +0 -10
- package/app/components/navbar/navbar.tsx +0 -22
- package/app/components/sidebar/case-import/case-import.module.css +7 -131
- package/app/components/sidebar/case-import/case-import.tsx +7 -14
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +17 -60
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +23 -39
- package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +5 -45
- package/app/components/sidebar/case-import/components/FileSelector.tsx +5 -6
- package/app/components/sidebar/case-import/hooks/useFilePreview.ts +2 -48
- package/app/components/sidebar/case-import/utils/file-validation.ts +9 -21
- package/app/config-example/config.json +5 -0
- package/app/routes/auth/login.tsx +1 -1
- package/app/utils/data/operations/signing-operations.ts +93 -0
- package/app/utils/data/operations/types.ts +6 -0
- package/app/utils/forensics/export-encryption.ts +316 -0
- package/app/utils/forensics/export-verification.ts +1 -409
- package/app/utils/forensics/index.ts +1 -0
- package/app/utils/ui/case-messages.ts +5 -2
- package/package.json +1 -1
- package/scripts/deploy-config.sh +97 -3
- package/scripts/deploy-worker-secrets.sh +1 -1
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/src/data-worker.example.ts +130 -0
- package/workers/data-worker/src/encryption-utils.ts +125 -0
- package/workers/data-worker/worker-configuration.d.ts +1 -1
- package/workers/data-worker/wrangler.jsonc.example +2 -2
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/keys-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
- package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +0 -287
- package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +0 -470
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
import styles from '../case-import.module.css';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
export interface ConfirmationPreview {
|
|
5
|
-
caseNumber: string;
|
|
6
|
-
fullName: string;
|
|
7
|
-
exportDate: string;
|
|
8
|
-
totalConfirmations: number;
|
|
9
|
-
confirmationIds: string[];
|
|
10
|
-
}
|
|
3
|
+
export type ConfirmationPreview = Record<string, never>;
|
|
11
4
|
|
|
12
5
|
interface ConfirmationPreviewSectionProps {
|
|
13
6
|
confirmationPreview: ConfirmationPreview | null;
|
|
@@ -29,43 +22,10 @@ export const ConfirmationPreviewSection = ({ confirmationPreview, isLoadingPrevi
|
|
|
29
22
|
|
|
30
23
|
return (
|
|
31
24
|
<div className={styles.previewSection}>
|
|
32
|
-
<h3 className={styles.previewTitle}>Confirmation
|
|
33
|
-
<
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
<span className={styles.previewValue}>{confirmationPreview.caseNumber}</span>
|
|
37
|
-
</div>
|
|
38
|
-
<div className={styles.previewItem}>
|
|
39
|
-
<span className={styles.previewLabel}>Exported by:</span>
|
|
40
|
-
<span className={styles.previewValue}>{confirmationPreview.fullName}</span>
|
|
41
|
-
</div>
|
|
42
|
-
<div className={styles.previewItem}>
|
|
43
|
-
<span className={styles.previewLabel}>Export Date:</span>
|
|
44
|
-
<span className={styles.previewValue}>
|
|
45
|
-
{new Date(confirmationPreview.exportDate).toLocaleDateString(undefined, {
|
|
46
|
-
year: 'numeric',
|
|
47
|
-
month: 'long',
|
|
48
|
-
day: 'numeric',
|
|
49
|
-
hour: '2-digit',
|
|
50
|
-
minute: '2-digit',
|
|
51
|
-
timeZoneName: 'short'
|
|
52
|
-
})}
|
|
53
|
-
</span>
|
|
54
|
-
</div>
|
|
55
|
-
<div className={styles.previewItem}>
|
|
56
|
-
<span className={styles.previewLabel}>Total Confirmations:</span>
|
|
57
|
-
<span className={styles.previewValue}>{confirmationPreview.totalConfirmations}</span>
|
|
58
|
-
</div>
|
|
59
|
-
<div className={styles.previewItem}>
|
|
60
|
-
<span className={styles.previewLabel}>Confirmation IDs:</span>
|
|
61
|
-
<span className={styles.previewValue}>
|
|
62
|
-
{confirmationPreview.confirmationIds.length > 0
|
|
63
|
-
? confirmationPreview.confirmationIds.join(', ')
|
|
64
|
-
: 'None'
|
|
65
|
-
}
|
|
66
|
-
</span>
|
|
67
|
-
</div>
|
|
68
|
-
</div>
|
|
25
|
+
<h3 className={styles.previewTitle}>Confirmation Import Preview</h3>
|
|
26
|
+
<p className={styles.previewMessage}>
|
|
27
|
+
Confirmation package detected. Details are hidden until import verification completes.
|
|
28
|
+
</p>
|
|
69
29
|
</div>
|
|
70
30
|
);
|
|
71
31
|
};
|
|
@@ -61,8 +61,7 @@ export const FileSelector = ({
|
|
|
61
61
|
const file = files[0];
|
|
62
62
|
|
|
63
63
|
// Check file type (same as input accept attribute)
|
|
64
|
-
const isValidType = file.name.toLowerCase().endsWith('.zip')
|
|
65
|
-
file.name.toLowerCase().endsWith('.json');
|
|
64
|
+
const isValidType = file.name.toLowerCase().endsWith('.zip');
|
|
66
65
|
|
|
67
66
|
if (isValidType) {
|
|
68
67
|
if (onFileSelectDirect) {
|
|
@@ -92,11 +91,11 @@ export const FileSelector = ({
|
|
|
92
91
|
ref={fileInputRef}
|
|
93
92
|
type="file"
|
|
94
93
|
id="zipFile"
|
|
95
|
-
accept=".zip
|
|
94
|
+
accept=".zip"
|
|
96
95
|
onChange={onFileSelect}
|
|
97
96
|
disabled={isDisabled}
|
|
98
97
|
className={styles.fileInput}
|
|
99
|
-
aria-label="File picker for ZIP
|
|
98
|
+
aria-label="File picker for ZIP packages"
|
|
100
99
|
/>
|
|
101
100
|
<div
|
|
102
101
|
className={`${styles.fileLabel} ${isDragOver ? styles.fileLabelDragOver : ''}`}
|
|
@@ -111,7 +110,7 @@ export const FileSelector = ({
|
|
|
111
110
|
role="button"
|
|
112
111
|
tabIndex={isDisabled ? -1 : 0}
|
|
113
112
|
aria-disabled={isDisabled}
|
|
114
|
-
aria-label="File selection area. Drag and drop a ZIP
|
|
113
|
+
aria-label="File selection area. Drag and drop a case ZIP or encrypted confirmation ZIP package for import."
|
|
115
114
|
onKeyDown={(e) => {
|
|
116
115
|
if ((e.key === 'Enter' || e.key === ' ') && !isDisabled) {
|
|
117
116
|
if (e.key === ' ') {
|
|
@@ -128,7 +127,7 @@ export const FileSelector = ({
|
|
|
128
127
|
? selectedFile.name
|
|
129
128
|
: isDragOver
|
|
130
129
|
? 'Drop file here...'
|
|
131
|
-
: 'Select ZIP
|
|
130
|
+
: 'Select ZIP package... or drag & drop'
|
|
132
131
|
}
|
|
133
132
|
</span>
|
|
134
133
|
</div>
|
|
@@ -4,11 +4,6 @@ import { previewCaseImport, extractConfirmationImportPackage } from '~/component
|
|
|
4
4
|
import { type CaseImportPreview } from '~/types';
|
|
5
5
|
import { type ConfirmationPreview } from '../components/ConfirmationPreviewSection';
|
|
6
6
|
|
|
7
|
-
type UnknownRecord = Record<string, unknown>;
|
|
8
|
-
|
|
9
|
-
const isRecord = (value: unknown): value is UnknownRecord =>
|
|
10
|
-
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
11
|
-
|
|
12
7
|
interface UseFilePreviewReturn {
|
|
13
8
|
casePreview: CaseImportPreview | null;
|
|
14
9
|
confirmationPreview: ConfirmationPreview | null;
|
|
@@ -56,50 +51,9 @@ export const useFilePreview = (
|
|
|
56
51
|
|
|
57
52
|
setIsLoadingPreview(true);
|
|
58
53
|
try {
|
|
59
|
-
|
|
60
|
-
const parsed = confirmationData as unknown;
|
|
61
|
-
|
|
62
|
-
if (!isRecord(parsed)) {
|
|
63
|
-
throw new Error('Invalid confirmation data format');
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const metadata = isRecord(parsed.metadata) ? parsed.metadata : undefined;
|
|
67
|
-
const confirmations = isRecord(parsed.confirmations) ? parsed.confirmations : undefined;
|
|
68
|
-
|
|
69
|
-
// Extract confirmation IDs from the confirmations object
|
|
70
|
-
const confirmationIds: string[] = [];
|
|
71
|
-
if (confirmations) {
|
|
72
|
-
Object.values(confirmations).forEach((imageConfirmations) => {
|
|
73
|
-
if (Array.isArray(imageConfirmations)) {
|
|
74
|
-
imageConfirmations.forEach((confirmation) => {
|
|
75
|
-
if (isRecord(confirmation) && typeof confirmation.confirmationId === 'string') {
|
|
76
|
-
confirmationIds.push(confirmation.confirmationId);
|
|
77
|
-
}
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const caseNumber =
|
|
84
|
-
metadata && typeof metadata.caseNumber === 'string' ? metadata.caseNumber : 'Unknown';
|
|
85
|
-
const fullName =
|
|
86
|
-
metadata && typeof metadata.exportedByName === 'string' ? metadata.exportedByName : 'Unknown';
|
|
87
|
-
const exportDate =
|
|
88
|
-
metadata && typeof metadata.exportDate === 'string'
|
|
89
|
-
? metadata.exportDate
|
|
90
|
-
: new Date().toISOString();
|
|
91
|
-
const totalConfirmations =
|
|
92
|
-
metadata && typeof metadata.totalConfirmations === 'number'
|
|
93
|
-
? metadata.totalConfirmations
|
|
94
|
-
: confirmationIds.length;
|
|
54
|
+
await extractConfirmationImportPackage(file);
|
|
95
55
|
|
|
96
|
-
const preview: ConfirmationPreview = {
|
|
97
|
-
caseNumber,
|
|
98
|
-
fullName,
|
|
99
|
-
exportDate,
|
|
100
|
-
totalConfirmations,
|
|
101
|
-
confirmationIds
|
|
102
|
-
};
|
|
56
|
+
const preview: ConfirmationPreview = {};
|
|
103
57
|
|
|
104
58
|
setConfirmationPreview(preview);
|
|
105
59
|
} catch (error) {
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { isConfirmationDataFile } from '~/components/actions/case-review';
|
|
2
|
-
|
|
3
1
|
const CASE_EXPORT_DATA_FILE_REGEX = /_data\.(json|csv)$/i;
|
|
4
2
|
const CONFIRMATION_EXPORT_FILE_REGEX = /^confirmation-data-.*\.json$/i;
|
|
5
3
|
const FORENSIC_MANIFEST_FILE_NAME = 'forensic_manifest.json';
|
|
4
|
+
const ENCRYPTION_MANIFEST_FILE_NAME = 'encryption_manifest.json';
|
|
6
5
|
|
|
7
6
|
function getLeafFileName(path: string): string {
|
|
8
7
|
const segments = path.split('/').filter(Boolean);
|
|
@@ -19,20 +18,10 @@ export const isValidZipFile = (file: File): boolean => {
|
|
|
19
18
|
};
|
|
20
19
|
|
|
21
20
|
/**
|
|
22
|
-
* Check if a file is
|
|
23
|
-
*/
|
|
24
|
-
export const isValidConfirmationFile = (file: File): boolean => {
|
|
25
|
-
const lowerName = file.name.toLowerCase();
|
|
26
|
-
const jsonType = file.type === 'application/json' || file.type === '';
|
|
27
|
-
|
|
28
|
-
return lowerName.endsWith('.json') && jsonType && isConfirmationDataFile(file.name);
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Check if a file is valid for import (either ZIP or confirmation JSON)
|
|
21
|
+
* Check if a file is valid for import (ZIP packages only)
|
|
33
22
|
*/
|
|
34
23
|
export const isValidImportFile = (file: File): boolean => {
|
|
35
|
-
return isValidZipFile(file)
|
|
24
|
+
return isValidZipFile(file);
|
|
36
25
|
};
|
|
37
26
|
|
|
38
27
|
/**
|
|
@@ -40,20 +29,15 @@ export const isValidImportFile = (file: File): boolean => {
|
|
|
40
29
|
*/
|
|
41
30
|
export const getImportType = (file: File): 'case' | 'confirmation' | null => {
|
|
42
31
|
if (isValidZipFile(file)) return 'case';
|
|
43
|
-
if (isValidConfirmationFile(file)) return 'confirmation';
|
|
44
32
|
return null;
|
|
45
33
|
};
|
|
46
34
|
|
|
47
35
|
/**
|
|
48
36
|
* Resolve import type, including ZIP package inspection.
|
|
49
37
|
* Case ZIPs are identified by case data files or FORENSIC_MANIFEST.json.
|
|
50
|
-
* Confirmation ZIPs are identified by confirmation-data-*.json.
|
|
38
|
+
* Confirmation ZIPs are identified by confirmation-data-*.json plus ENCRYPTION_MANIFEST.json.
|
|
51
39
|
*/
|
|
52
40
|
export const resolveImportType = async (file: File): Promise<'case' | 'confirmation' | null> => {
|
|
53
|
-
if (isValidConfirmationFile(file)) {
|
|
54
|
-
return 'confirmation';
|
|
55
|
-
}
|
|
56
|
-
|
|
57
41
|
if (!isValidZipFile(file)) {
|
|
58
42
|
return null;
|
|
59
43
|
}
|
|
@@ -78,7 +62,11 @@ export const resolveImportType = async (file: File): Promise<'case' | 'confirmat
|
|
|
78
62
|
CONFIRMATION_EXPORT_FILE_REGEX.test(getLeafFileName(path))
|
|
79
63
|
);
|
|
80
64
|
|
|
81
|
-
|
|
65
|
+
const hasEncryptionManifest = fileEntries.some(
|
|
66
|
+
(path) => getLeafFileName(path).toLowerCase() === ENCRYPTION_MANIFEST_FILE_NAME
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
if (hasConfirmationData && hasEncryptionManifest) {
|
|
82
70
|
return 'confirmation';
|
|
83
71
|
}
|
|
84
72
|
|
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
"manifest_signing_public_keys": {
|
|
7
7
|
"MANIFEST_SIGNING_KEY_ID": "MANIFEST_SIGNING_PUBLIC_KEY"
|
|
8
8
|
},
|
|
9
|
+
"export_encryption_key_id": "EXPORT_ENCRYPTION_KEY_ID",
|
|
10
|
+
"export_encryption_public_key": "EXPORT_ENCRYPTION_PUBLIC_KEY",
|
|
11
|
+
"export_encryption_public_keys": {
|
|
12
|
+
"EXPORT_ENCRYPTION_KEY_ID": "EXPORT_ENCRYPTION_PUBLIC_KEY"
|
|
13
|
+
},
|
|
9
14
|
"max_cases_review": 0,
|
|
10
15
|
"max_files_per_case_review": 0
|
|
11
16
|
}
|
|
@@ -28,7 +28,7 @@ import { generateUniqueId } from '~/utils/common';
|
|
|
28
28
|
import { evaluatePasswordPolicy, buildActionCodeSettings, userHasMFA } from '~/utils/auth';
|
|
29
29
|
import type { UserData } from '~/types';
|
|
30
30
|
|
|
31
|
-
const APP_CANONICAL_ORIGIN = '
|
|
31
|
+
const APP_CANONICAL_ORIGIN = 'https://striae.app';
|
|
32
32
|
const SOCIAL_IMAGE_PATH = '/social-image.png';
|
|
33
33
|
const SOCIAL_IMAGE_ALT = 'Striae forensic annotation and comparison workspace';
|
|
34
34
|
const LOGIN_PATH_ALIASES = new Set(['/auth', '/auth/', '/auth/login', '/auth/login/']);
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type ForensicManifestSignature,
|
|
14
14
|
FORENSIC_MANIFEST_VERSION
|
|
15
15
|
} from '../../forensics/SHA256';
|
|
16
|
+
import type { EncryptionManifest } from '../../forensics/export-encryption';
|
|
16
17
|
import { canAccessCase, validateUserSession } from '../permissions';
|
|
17
18
|
import type {
|
|
18
19
|
AuditExportSigningResponse,
|
|
@@ -223,3 +224,95 @@ export const signAuditExportData = async (
|
|
|
223
224
|
throw error;
|
|
224
225
|
}
|
|
225
226
|
};
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Request batch decryption of export data file and images from the data worker
|
|
230
|
+
*/
|
|
231
|
+
export const decryptExportBatch = async (
|
|
232
|
+
user: User,
|
|
233
|
+
encryptionManifest: EncryptionManifest,
|
|
234
|
+
encryptedDataBase64: string,
|
|
235
|
+
encryptedImageMap: Record<string, string>
|
|
236
|
+
): Promise<{ plaintext: string; decryptedImages: Record<string, Blob> }> => {
|
|
237
|
+
try {
|
|
238
|
+
const sessionValidation = await validateUserSession(user);
|
|
239
|
+
if (!sessionValidation.valid) {
|
|
240
|
+
throw new Error(`Session validation failed: ${sessionValidation.reason}`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Convert encryptedImageMap to array format expected by worker, including per-image IV from manifest
|
|
244
|
+
const encryptedImages = Object.entries(encryptedImageMap).map(([filename, encryptedData]) => {
|
|
245
|
+
const manifestEntry = encryptionManifest.encryptedImages.find(e => e.filename === filename);
|
|
246
|
+
return {
|
|
247
|
+
filename,
|
|
248
|
+
encryptedData,
|
|
249
|
+
iv: manifestEntry?.iv
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const response = await fetchDataApi(user, '/api/forensic/decrypt-export', {
|
|
254
|
+
method: 'POST',
|
|
255
|
+
headers: {
|
|
256
|
+
'Content-Type': 'application/json'
|
|
257
|
+
},
|
|
258
|
+
body: JSON.stringify({
|
|
259
|
+
userId: user.uid,
|
|
260
|
+
wrappedKey: encryptionManifest.wrappedKey,
|
|
261
|
+
dataIv: encryptionManifest.dataIv,
|
|
262
|
+
encryptedData: encryptedDataBase64,
|
|
263
|
+
encryptedImages,
|
|
264
|
+
keyId: encryptionManifest.keyId
|
|
265
|
+
})
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const responseData = await response.json().catch(() => null) as {
|
|
269
|
+
success?: boolean;
|
|
270
|
+
error?: string;
|
|
271
|
+
plaintext?: string;
|
|
272
|
+
decryptedImages?: Array<{ filename: string; data: string }>;
|
|
273
|
+
} | null;
|
|
274
|
+
|
|
275
|
+
if (!response.ok) {
|
|
276
|
+
const errorMessage = responseData?.error || `Failed to decrypt export: ${response.status} ${response.statusText}`;
|
|
277
|
+
|
|
278
|
+
// Special handling for encrypted exports without configured key
|
|
279
|
+
if (response.status === 400 && errorMessage.includes('not configured')) {
|
|
280
|
+
throw new Error(
|
|
281
|
+
'This export is encrypted. To import it, your Striae instance must have EXPORT_ENCRYPTION_PRIVATE_KEY configured.'
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
throw new Error(errorMessage);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (!responseData?.success || !responseData.plaintext) {
|
|
289
|
+
throw new Error('Invalid decrypt response from data worker');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Convert decrypted image base64 data back to Blobs
|
|
293
|
+
const decryptedImages: Record<string, Blob> = {};
|
|
294
|
+
if (Array.isArray(responseData.decryptedImages)) {
|
|
295
|
+
for (const imageEntry of responseData.decryptedImages) {
|
|
296
|
+
try {
|
|
297
|
+
const binaryString = atob(imageEntry.data);
|
|
298
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
299
|
+
for (let i = 0; i < binaryString.length; i++) {
|
|
300
|
+
bytes[i] = binaryString.charCodeAt(i);
|
|
301
|
+
}
|
|
302
|
+
decryptedImages[imageEntry.filename] = new Blob([bytes]);
|
|
303
|
+
} catch (error) {
|
|
304
|
+
console.error(`Failed to convert decrypted image ${imageEntry.filename}:`, error);
|
|
305
|
+
throw new Error(`Failed to convert decrypted image: ${imageEntry.filename}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
plaintext: responseData.plaintext,
|
|
312
|
+
decryptedImages
|
|
313
|
+
};
|
|
314
|
+
} catch (error) {
|
|
315
|
+
console.error('Error decrypting export batch:', error);
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
};
|
|
@@ -39,4 +39,10 @@ export interface AuditExportSigningResponse {
|
|
|
39
39
|
signature: ForensicManifestSignature;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
export interface DecryptExportBatchResponse {
|
|
43
|
+
success: boolean;
|
|
44
|
+
plaintext: string;
|
|
45
|
+
decryptedImages: Array<{ filename: string; data: string }>;
|
|
46
|
+
}
|
|
47
|
+
|
|
42
48
|
export type DataOperation<T> = (user: User, ...args: unknown[]) => Promise<T>;
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import paths from '~/config/config.json';
|
|
2
|
+
|
|
3
|
+
export const EXPORT_ENCRYPTION_VERSION = '1.0';
|
|
4
|
+
export const EXPORT_ENCRYPTION_ALGORITHM = 'RSA-OAEP-AES-256-GCM';
|
|
5
|
+
|
|
6
|
+
export interface EncryptedImageEntry {
|
|
7
|
+
filename: string;
|
|
8
|
+
encryptedHash: string; // SHA256 of encrypted bytes (lowercase hex)
|
|
9
|
+
iv: string; // base64url — per-image nonce
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface EncryptionManifest {
|
|
13
|
+
encryptionVersion: string;
|
|
14
|
+
algorithm: string;
|
|
15
|
+
keyId: string;
|
|
16
|
+
wrappedKey: string; // base64url
|
|
17
|
+
dataIv: string; // base64url — nonce for the data file
|
|
18
|
+
encryptedImages: EncryptedImageEntry[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface EncryptedExportResult {
|
|
22
|
+
ciphertext: Uint8Array;
|
|
23
|
+
encryptedImages: Uint8Array[];
|
|
24
|
+
encryptionManifest: EncryptionManifest;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PublicEncryptionKeyDetails {
|
|
28
|
+
keyId: string | null;
|
|
29
|
+
publicKeyPem: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type ManifestEncryptionConfig = {
|
|
33
|
+
export_encryption_key_id?: string;
|
|
34
|
+
export_encryption_public_key?: string;
|
|
35
|
+
export_encryption_public_keys?: Record<string, string>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function base64UrlEncode(value: Uint8Array): string {
|
|
39
|
+
let binary = '';
|
|
40
|
+
for (const byte of value) {
|
|
41
|
+
binary += String.fromCharCode(byte);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return btoa(binary)
|
|
45
|
+
.replace(/\+/g, '-')
|
|
46
|
+
.replace(/\//g, '_')
|
|
47
|
+
.replace(/=+$/g, '');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function base64UrlDecode(value: string): Uint8Array {
|
|
51
|
+
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
52
|
+
const padding = '='.repeat((4 - (normalized.length % 4)) % 4);
|
|
53
|
+
const decoded = atob(normalized + padding);
|
|
54
|
+
const bytes = new Uint8Array(decoded.length);
|
|
55
|
+
|
|
56
|
+
for (let i = 0; i < decoded.length; i += 1) {
|
|
57
|
+
bytes[i] = decoded.charCodeAt(i);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return bytes;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizePemPublicKey(pem: string): string {
|
|
64
|
+
return pem.replace(/\\n/g, '\n').trim();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function publicKeyPemToArrayBuffer(publicKeyPem: string): ArrayBuffer {
|
|
68
|
+
const normalized = normalizePemPublicKey(publicKeyPem);
|
|
69
|
+
const pemBody = normalized
|
|
70
|
+
.replace('-----BEGIN PUBLIC KEY-----', '')
|
|
71
|
+
.replace('-----END PUBLIC KEY-----', '')
|
|
72
|
+
.replace(/\s+/g, '');
|
|
73
|
+
|
|
74
|
+
if (!pemBody) {
|
|
75
|
+
throw new Error('Encryption public key is invalid');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const binary = atob(pemBody);
|
|
79
|
+
const bytes = new Uint8Array(binary.length);
|
|
80
|
+
|
|
81
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
82
|
+
bytes[index] = binary.charCodeAt(index);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return bytes.buffer;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function importRsaOaepPublicKey(publicKeyPem: string): Promise<CryptoKey> {
|
|
89
|
+
const key = await crypto.subtle.importKey(
|
|
90
|
+
'spki',
|
|
91
|
+
publicKeyPemToArrayBuffer(publicKeyPem),
|
|
92
|
+
{
|
|
93
|
+
name: 'RSA-OAEP',
|
|
94
|
+
hash: 'SHA-256'
|
|
95
|
+
},
|
|
96
|
+
false,
|
|
97
|
+
['encrypt']
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return key;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function getCurrentEncryptionPublicKeyDetails(): PublicEncryptionKeyDetails {
|
|
104
|
+
const config = paths as unknown as ManifestEncryptionConfig;
|
|
105
|
+
const configuredKeyId =
|
|
106
|
+
typeof config.export_encryption_key_id === 'string' &&
|
|
107
|
+
config.export_encryption_key_id.trim().length > 0
|
|
108
|
+
? config.export_encryption_key_id
|
|
109
|
+
: null;
|
|
110
|
+
|
|
111
|
+
if (configuredKeyId) {
|
|
112
|
+
const configuredKey = getEncryptionPublicKey(configuredKeyId);
|
|
113
|
+
if (configuredKey) {
|
|
114
|
+
return {
|
|
115
|
+
keyId: configuredKeyId,
|
|
116
|
+
publicKeyPem: configuredKey
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const keyMap = config.export_encryption_public_keys;
|
|
122
|
+
if (keyMap && typeof keyMap === 'object') {
|
|
123
|
+
const firstConfiguredEntry = Object.entries(keyMap).find(
|
|
124
|
+
([, value]) => typeof value === 'string' && value.trim().length > 0
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
if (firstConfiguredEntry) {
|
|
128
|
+
return {
|
|
129
|
+
keyId: firstConfiguredEntry[0],
|
|
130
|
+
publicKeyPem: normalizePemPublicKey(firstConfiguredEntry[1])
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
keyId: null,
|
|
137
|
+
publicKeyPem:
|
|
138
|
+
typeof config.export_encryption_public_key === 'string' &&
|
|
139
|
+
config.export_encryption_public_key.trim().length > 0
|
|
140
|
+
? normalizePemPublicKey(config.export_encryption_public_key)
|
|
141
|
+
: null
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function getEncryptionPublicKey(keyId: string): string | null {
|
|
146
|
+
const config = paths as unknown as ManifestEncryptionConfig;
|
|
147
|
+
const keyMap = config.export_encryption_public_keys;
|
|
148
|
+
|
|
149
|
+
if (keyMap && typeof keyMap === 'object') {
|
|
150
|
+
const mappedKey = keyMap[keyId];
|
|
151
|
+
if (typeof mappedKey === 'string' && mappedKey.trim().length > 0) {
|
|
152
|
+
return normalizePemPublicKey(mappedKey);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (
|
|
157
|
+
typeof config.export_encryption_key_id === 'string' &&
|
|
158
|
+
config.export_encryption_key_id === keyId &&
|
|
159
|
+
typeof config.export_encryption_public_key === 'string' &&
|
|
160
|
+
config.export_encryption_public_key.trim().length > 0
|
|
161
|
+
) {
|
|
162
|
+
return normalizePemPublicKey(config.export_encryption_public_key);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Generate a shared AES-256-GCM key for all exports in one batch
|
|
170
|
+
*/
|
|
171
|
+
export async function generateSharedAesKey(): Promise<CryptoKey> {
|
|
172
|
+
return crypto.subtle.generateKey(
|
|
173
|
+
{ name: 'AES-GCM', length: 256 },
|
|
174
|
+
true, // extractable for wrapping
|
|
175
|
+
['encrypt', 'decrypt']
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Encrypt plaintext data file with shared AES key
|
|
181
|
+
*/
|
|
182
|
+
export async function encryptDataWithSharedKey(
|
|
183
|
+
plaintextString: string,
|
|
184
|
+
sharedAesKey: CryptoKey,
|
|
185
|
+
iv: Uint8Array
|
|
186
|
+
): Promise<Uint8Array> {
|
|
187
|
+
const plaintext = new TextEncoder().encode(plaintextString);
|
|
188
|
+
|
|
189
|
+
const ciphertext = await crypto.subtle.encrypt(
|
|
190
|
+
{ name: 'AES-GCM', iv: iv as BufferSource },
|
|
191
|
+
sharedAesKey,
|
|
192
|
+
plaintext
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
return new Uint8Array(ciphertext);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Encrypt a single image blob with shared AES key, return ciphertext and SHA256 hash
|
|
200
|
+
*/
|
|
201
|
+
export async function encryptImageWithSharedKey(
|
|
202
|
+
imageBlob: Blob,
|
|
203
|
+
sharedAesKey: CryptoKey,
|
|
204
|
+
iv: Uint8Array
|
|
205
|
+
): Promise<{ ciphertext: Uint8Array; hash: string }> {
|
|
206
|
+
const imageBuffer = await imageBlob.arrayBuffer();
|
|
207
|
+
const imageBytes = new Uint8Array(imageBuffer);
|
|
208
|
+
|
|
209
|
+
const ciphertext = await crypto.subtle.encrypt(
|
|
210
|
+
{ name: 'AES-GCM', iv: iv as BufferSource },
|
|
211
|
+
sharedAesKey,
|
|
212
|
+
imageBytes
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const ciphertextBytes = new Uint8Array(ciphertext);
|
|
216
|
+
|
|
217
|
+
// Calculate SHA256 of encrypted bytes
|
|
218
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', ciphertextBytes);
|
|
219
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
220
|
+
const hash = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
ciphertext: ciphertextBytes,
|
|
224
|
+
hash: hash.toLowerCase()
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Wrap AES key with RSA-OAEP public key
|
|
230
|
+
*/
|
|
231
|
+
export async function wrapAesKeyWithPublicKey(
|
|
232
|
+
aesKey: CryptoKey,
|
|
233
|
+
publicKeyPem: string
|
|
234
|
+
): Promise<string> {
|
|
235
|
+
const rsaPublicKey = await importRsaOaepPublicKey(publicKeyPem);
|
|
236
|
+
|
|
237
|
+
// Export the AES key to raw format
|
|
238
|
+
const rawAesKey = await crypto.subtle.exportKey('raw', aesKey);
|
|
239
|
+
|
|
240
|
+
// Wrap the raw AES key with RSA-OAEP
|
|
241
|
+
const wrappedKey = await crypto.subtle.encrypt(
|
|
242
|
+
{ name: 'RSA-OAEP' },
|
|
243
|
+
rsaPublicKey,
|
|
244
|
+
rawAesKey
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
return base64UrlEncode(new Uint8Array(wrappedKey));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Encrypt export data file and all images with a shared AES-256 key
|
|
252
|
+
* Returns ciphertext, encrypted image array, and encryption manifest
|
|
253
|
+
*/
|
|
254
|
+
export async function encryptExportDataWithAllImages(
|
|
255
|
+
plaintextString: string,
|
|
256
|
+
imageBlobs: Array<{ filename: string; blob: Blob }>,
|
|
257
|
+
publicKeyPem: string,
|
|
258
|
+
keyId: string
|
|
259
|
+
): Promise<EncryptedExportResult> {
|
|
260
|
+
// Generate shared AES-256 key
|
|
261
|
+
const sharedAesKey = await generateSharedAesKey();
|
|
262
|
+
|
|
263
|
+
// Generate a unique 96-bit IV for the data file
|
|
264
|
+
const dataIv = crypto.getRandomValues(new Uint8Array(12));
|
|
265
|
+
const dataIvBase64 = base64UrlEncode(dataIv);
|
|
266
|
+
|
|
267
|
+
// Encrypt data file with its own IV
|
|
268
|
+
const dataCiphertext = await encryptDataWithSharedKey(
|
|
269
|
+
plaintextString,
|
|
270
|
+
sharedAesKey,
|
|
271
|
+
dataIv
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
// Encrypt all images — each with its own unique IV
|
|
275
|
+
const encryptedImages: Uint8Array[] = [];
|
|
276
|
+
const encryptedImageEntries: EncryptedImageEntry[] = [];
|
|
277
|
+
|
|
278
|
+
for (const { filename, blob } of imageBlobs) {
|
|
279
|
+
const imageIv = crypto.getRandomValues(new Uint8Array(12));
|
|
280
|
+
const imageIvBase64 = base64UrlEncode(imageIv);
|
|
281
|
+
|
|
282
|
+
const { ciphertext, hash } = await encryptImageWithSharedKey(
|
|
283
|
+
blob,
|
|
284
|
+
sharedAesKey,
|
|
285
|
+
imageIv
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
encryptedImages.push(ciphertext);
|
|
289
|
+
encryptedImageEntries.push({
|
|
290
|
+
filename,
|
|
291
|
+
encryptedHash: hash,
|
|
292
|
+
iv: imageIvBase64
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Wrap shared AES key with RSA-OAEP
|
|
297
|
+
const wrappedKeyBase64 = await wrapAesKeyWithPublicKey(
|
|
298
|
+
sharedAesKey,
|
|
299
|
+
publicKeyPem
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const encryptionManifest: EncryptionManifest = {
|
|
303
|
+
encryptionVersion: EXPORT_ENCRYPTION_VERSION,
|
|
304
|
+
algorithm: EXPORT_ENCRYPTION_ALGORITHM,
|
|
305
|
+
keyId,
|
|
306
|
+
wrappedKey: wrappedKeyBase64,
|
|
307
|
+
dataIv: dataIvBase64,
|
|
308
|
+
encryptedImages: encryptedImageEntries
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
ciphertext: dataCiphertext,
|
|
313
|
+
encryptedImages,
|
|
314
|
+
encryptionManifest
|
|
315
|
+
};
|
|
316
|
+
}
|