Skip to content

Commit 5145e85

Browse files
authored
test: more js testing (#161)
1 parent 962b870 commit 5145e85

7 files changed

Lines changed: 639 additions & 321 deletions

File tree

dashboard_action/runtime/scripts/render_dashboard_support/assets/static/dashboard/entry-secure.js

Lines changed: 2 additions & 315 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createDashboardApp } from './app.js';
22
import { readJsonAsset } from './json-assets.js';
3+
import { buildExportFilename, decryptBytes, decryptDashboardData, deriveAesKey, formatDelay, nextUnlockDelayMs, sha256Hex, unlockAttemptStorageKey as buildUnlockAttemptStorageKey, validateEncryptedExportManifest } from './secure-core.js';
34

45
const app = createDashboardApp();
56
const encryptedDashboardData = await readJsonAsset(
@@ -14,14 +15,6 @@ const exportManifestPayload = await readJsonAsset(
1415
{ optional: true }
1516
);
1617

17-
const EXPECTED_DASHBOARD_DATA_VERSION = 2;
18-
const EXPECTED_CIPHER = 'AES-GCM';
19-
const EXPECTED_KDF_NAME = 'PBKDF2';
20-
const EXPECTED_KDF_HASH = 'SHA-256';
21-
const EXPECTED_KDF_ITERATIONS = __PBKDF2_ITERATIONS__;
22-
const EXPECTED_SALT_BYTES = 16;
23-
const EXPECTED_IV_BYTES = 12;
24-
2518
const authShell = document.getElementById('auth-shell');
2619
const unlockForm = document.getElementById('unlock-form');
2720
const dashboardKeyInput = document.getElementById('dashboard-key');
@@ -35,10 +28,6 @@ const EXPECTED_DASHBOARD_DATA_VERSION = 2;
3528
const exportStatus = document.getElementById('export-status');
3629
const EXPORT_BUTTON_LABEL = '📄 Export to CSV';
3730
const EXPORT_BUTTON_WORKING_LABEL = 'Preparing…';
38-
const UNLOCK_ATTEMPT_STORAGE_PREFIX = 'reponomics-unlock-attempts:';
39-
const UNLOCK_DELAY_STARTS_AT = 3;
40-
const UNLOCK_DELAY_BASE_MS = 2000;
41-
const UNLOCK_DELAY_MAX_MS = 30000;
4231
let unlockedExportKey = null;
4332
let unlockDelayTimer = null;
4433

@@ -47,26 +36,8 @@ const EXPECTED_DASHBOARD_DATA_VERSION = 2;
4736
unlockStatus.className = 'auth-status' + (type ? ' ' + type : '');
4837
}
4938

50-
function dashboardDataError(stage, message, details) {
51-
const error = new Error(message);
52-
error.dashboardDataStage = stage;
53-
if (details) {
54-
Object.keys(details).forEach((key) => {
55-
error[key] = details[key];
56-
});
57-
}
58-
return error;
59-
}
60-
6139
function unlockAttemptStorageKey() {
62-
const fingerprint = [
63-
encryptedDashboardData.version,
64-
encryptedDashboardData.cipher,
65-
encryptedDashboardData.salt,
66-
String(encryptedDashboardData.summary || '').slice(0, 32),
67-
String(encryptedDashboardData.chunk_count || 0)
68-
].join(':');
69-
return UNLOCK_ATTEMPT_STORAGE_PREFIX + fingerprint;
40+
return buildUnlockAttemptStorageKey(encryptedDashboardData);
7041
}
7142

7243
function readUnlockAttemptState() {
@@ -96,21 +67,6 @@ const EXPECTED_DASHBOARD_DATA_VERSION = 2;
9667
} catch (_error) { /* ignore */ }
9768
}
9869

99-
function nextUnlockDelayMs(failures) {
100-
if (failures < UNLOCK_DELAY_STARTS_AT) {
101-
return 0;
102-
}
103-
const exponent = failures - UNLOCK_DELAY_STARTS_AT;
104-
return Math.min(
105-
UNLOCK_DELAY_MAX_MS,
106-
UNLOCK_DELAY_BASE_MS * Math.pow(2, exponent)
107-
);
108-
}
109-
110-
function formatDelay(seconds) {
111-
return seconds === 1 ? '1 second' : seconds + ' seconds';
112-
}
113-
11470
function startUnlockDelay(delayMs, prefix) {
11571
if (unlockDelayTimer) {
11672
clearTimeout(unlockDelayTimer);
@@ -163,281 +119,12 @@ const EXPECTED_DASHBOARD_DATA_VERSION = 2;
163119
exportHashButton.disabled = false;
164120
}
165121

166-
function b64ToBytes(value) {
167-
if (typeof value !== 'string' || !value) {
168-
throw new Error('Invalid encrypted dashboard data.');
169-
}
170-
const binary = atob(value);
171-
const bytes = new Uint8Array(binary.length);
172-
for (let i = 0; i < binary.length; i += 1) {
173-
bytes[i] = binary.charCodeAt(i);
174-
}
175-
return bytes;
176-
}
177-
178-
function b64urlToBytes(value) {
179-
if (typeof value !== 'string' || !value) {
180-
throw new Error('Invalid encrypted dashboard data.');
181-
}
182-
const padded = value.replace(/-/g, '+').replace(/_/g, '/')
183-
+ '='.repeat((4 - (value.length % 4)) % 4);
184-
return b64ToBytes(padded);
185-
}
186-
187-
function bytesToHex(bytes) {
188-
return Array.from(bytes)
189-
.map((value) => value.toString(16).padStart(2, '0'))
190-
.join('');
191-
}
192-
193-
function buildExportFilename(prefix) {
194-
const now = new Date();
195-
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
196-
const safePrefix = String(prefix || 'reponomics-export')
197-
.replace(/\.zip$/i, '')
198-
.replace(/[^A-Za-z0-9._-]+/g, '-')
199-
.replace(/^-+|-+$/g, '') || 'reponomics-export';
200-
return safePrefix + '-' + stamp + '.zip';
201-
}
202-
203-
function validateEncryptedBlob(token) {
204-
if (typeof token !== 'string') {
205-
throw new Error('Invalid encrypted dashboard data.');
206-
}
207-
const parts = token.split('.');
208-
if (parts.length !== 2) {
209-
throw new Error('Invalid encrypted dashboard data.');
210-
}
211-
const iv = b64urlToBytes(parts[0]);
212-
const ciphertext = b64urlToBytes(parts[1]);
213-
if (iv.length !== EXPECTED_IV_BYTES || ciphertext.length === 0) {
214-
throw new Error('Invalid encrypted dashboard data.');
215-
}
216-
return { iv, ciphertext };
217-
}
218-
219-
function validateEncryptedDashboardData(data) {
220-
if (!data || data.version !== EXPECTED_DASHBOARD_DATA_VERSION) {
221-
throw new Error('Invalid encrypted dashboard data.');
222-
}
223-
if (data.cipher !== EXPECTED_CIPHER) {
224-
throw new Error('Invalid encrypted dashboard data.');
225-
}
226-
if (
227-
!data.kdf ||
228-
data.kdf.name !== EXPECTED_KDF_NAME ||
229-
data.kdf.hash !== EXPECTED_KDF_HASH ||
230-
data.kdf.iterations !== EXPECTED_KDF_ITERATIONS
231-
) {
232-
throw new Error('Invalid encrypted dashboard data.');
233-
}
234-
if (data.encoding !== 'gzip+json') {
235-
throw new Error('Invalid encrypted dashboard data.');
236-
}
237-
const salt = b64ToBytes(data.salt);
238-
if (salt.length !== EXPECTED_SALT_BYTES) {
239-
throw new Error('Invalid encrypted dashboard data.');
240-
}
241-
validateEncryptedBlob(data.summary);
242-
if (!data.chunks || typeof data.chunks !== 'object' || Array.isArray(data.chunks)) {
243-
throw new Error('Invalid encrypted dashboard data.');
244-
}
245-
const chunkIds = Object.keys(data.chunks);
246-
if (data.chunk_count !== chunkIds.length) {
247-
throw new Error('Invalid encrypted dashboard data.');
248-
}
249-
chunkIds.forEach((chunkId) => {
250-
if (!/^c[0-9]{4,}$/.test(chunkId)) {
251-
throw new Error('Invalid encrypted dashboard data.');
252-
}
253-
validateEncryptedBlob(data.chunks[chunkId]);
254-
});
255-
return { salt };
256-
}
257-
258-
function validateEncryptedExportManifest(manifest) {
259-
if (!manifest || manifest.version !== 1) {
260-
throw new Error('Invalid encrypted export metadata.');
261-
}
262-
if (manifest.cipher !== EXPECTED_CIPHER) {
263-
throw new Error('Invalid encrypted export metadata.');
264-
}
265-
if (
266-
!manifest.kdf ||
267-
manifest.kdf.name !== EXPECTED_KDF_NAME ||
268-
manifest.kdf.hash !== EXPECTED_KDF_HASH ||
269-
manifest.kdf.iterations !== EXPECTED_KDF_ITERATIONS
270-
) {
271-
throw new Error('Invalid encrypted export metadata.');
272-
}
273-
if (typeof manifest.asset !== 'string' || !manifest.asset) {
274-
throw new Error('Invalid encrypted export metadata.');
275-
}
276-
if (typeof manifest.filename !== 'string' || !manifest.filename) {
277-
throw new Error('Invalid encrypted export metadata.');
278-
}
279-
if (!Number.isInteger(manifest.ciphertext_size) || manifest.ciphertext_size <= 0) {
280-
throw new Error('Invalid encrypted export metadata.');
281-
}
282-
if (
283-
typeof manifest.ciphertext_sha256 !== 'string' ||
284-
!/^[a-f0-9]{64}$/.test(manifest.ciphertext_sha256)
285-
) {
286-
throw new Error('Invalid encrypted export metadata.');
287-
}
288-
if (
289-
typeof manifest.plaintext_sha256 !== 'string' ||
290-
!/^[a-f0-9]{64}$/.test(manifest.plaintext_sha256)
291-
) {
292-
throw new Error('Invalid encrypted export metadata.');
293-
}
294-
if (!/^assets\/export-data-[a-f0-9]{16}\.enc$/.test(manifest.asset)) {
295-
throw new Error('Invalid encrypted export metadata.');
296-
}
297-
const salt = b64ToBytes(manifest.salt);
298-
const iv = b64ToBytes(manifest.iv);
299-
if (salt.length !== EXPECTED_SALT_BYTES || iv.length !== EXPECTED_IV_BYTES) {
300-
throw new Error('Invalid encrypted export metadata.');
301-
}
302-
return { ...manifest, salt, iv };
303-
}
304-
305-
async function deriveAesKey(dashboardKey, salt) {
306-
const encoder = new TextEncoder();
307-
const keyMaterial = await crypto.subtle.importKey(
308-
'raw',
309-
encoder.encode(dashboardKey),
310-
'PBKDF2',
311-
false,
312-
['deriveKey']
313-
);
314-
return crypto.subtle.deriveKey(
315-
{
316-
name: EXPECTED_KDF_NAME,
317-
salt,
318-
iterations: EXPECTED_KDF_ITERATIONS,
319-
hash: EXPECTED_KDF_HASH
320-
},
321-
keyMaterial,
322-
{ name: EXPECTED_CIPHER, length: 256 },
323-
false,
324-
['decrypt']
325-
);
326-
}
327-
328-
async function decryptBytes(key, iv, ciphertext) {
329-
return crypto.subtle.decrypt(
330-
{ name: EXPECTED_CIPHER, iv },
331-
key,
332-
ciphertext
333-
);
334-
}
335-
336-
async function gunzipJson(bytes, context) {
337-
if (!window.DecompressionStream) {
338-
throw dashboardDataError('decompress', 'Browser does not support gzip decompression.', context);
339-
}
340-
let decompressed;
341-
try {
342-
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'));
343-
decompressed = await new Response(stream).arrayBuffer();
344-
} catch (error) {
345-
throw dashboardDataError('decompress', error.message || 'Dashboard data could not be decompressed.', {
346-
...context,
347-
originalName: error.name || '',
348-
originalMessage: error.message || String(error)
349-
});
350-
}
351-
try {
352-
return JSON.parse(new TextDecoder().decode(decompressed));
353-
} catch (error) {
354-
throw dashboardDataError('parse', error.message || 'Dashboard data was not valid JSON.', {
355-
...context,
356-
originalName: error.name || '',
357-
originalMessage: error.message || String(error)
358-
});
359-
}
360-
}
361-
362-
async function decryptDashboardBlob(key, token, context) {
363-
const details = context || {};
364-
let blob;
365-
try {
366-
blob = validateEncryptedBlob(token);
367-
} catch (error) {
368-
throw dashboardDataError('schema', error.message || 'Encrypted dashboard blob was malformed.', {
369-
...details,
370-
originalName: error.name || '',
371-
originalMessage: error.message || String(error)
372-
});
373-
}
374-
let compressed;
375-
try {
376-
compressed = await decryptBytes(key, blob.iv, blob.ciphertext);
377-
} catch (error) {
378-
throw dashboardDataError('decrypt', error.message || 'Encrypted dashboard blob failed authentication.', {
379-
...details,
380-
originalName: error.name || '',
381-
originalMessage: error.message || String(error)
382-
});
383-
}
384-
return gunzipJson(new Uint8Array(compressed), details);
385-
}
386-
387-
async function decryptDashboardData(dashboardKey, data) {
388-
const validatedData = validateEncryptedDashboardData(data);
389-
const displayKey = await deriveAesKey(dashboardKey, validatedData.salt);
390-
const summary = await decryptDashboardBlob(displayKey, data.summary, {
391-
mode: 'encrypted',
392-
summaryDecrypted: false,
393-
stageTarget: 'summary'
394-
});
395-
const repoChunks = summary.repo_chunks || {};
396-
return {
397-
summary,
398-
loadRepoChunk: async function(repoName) {
399-
const chunkId = repoChunks[repoName];
400-
if (!chunkId || !data.chunks[chunkId]) {
401-
throw dashboardDataError('missing', 'Encrypted dashboard chunk was missing.', {
402-
repoName,
403-
chunkId: chunkId || '',
404-
mode: 'encrypted',
405-
summaryDecrypted: true,
406-
stageTarget: 'chunk'
407-
});
408-
}
409-
const chunk = await decryptDashboardBlob(displayKey, data.chunks[chunkId], {
410-
repoName,
411-
chunkId,
412-
mode: 'encrypted',
413-
summaryDecrypted: true,
414-
stageTarget: 'chunk'
415-
});
416-
if (!chunk.repo || chunk.repo !== repoName) {
417-
throw dashboardDataError('schema', 'Encrypted dashboard chunk did not match the requested repository.', {
418-
repoName,
419-
chunkId,
420-
mode: 'encrypted',
421-
summaryDecrypted: true,
422-
stageTarget: 'chunk'
423-
});
424-
}
425-
return chunk;
426-
}
427-
};
428-
}
429-
430122
async function deriveExportKey(dashboardKey) {
431123
if (!exportManifestPayload) return null;
432124
const manifest = validateEncryptedExportManifest(exportManifestPayload);
433125
return deriveAesKey(dashboardKey, manifest.salt);
434126
}
435127

436-
async function sha256Hex(bytes) {
437-
const digest = await crypto.subtle.digest('SHA-256', bytes);
438-
return bytesToHex(new Uint8Array(digest));
439-
}
440-
441128
function triggerDownload(filename, bytes) {
442129
const blob = new Blob([bytes], { type: 'application/zip' });
443130
const objectUrl = URL.createObjectURL(blob);

0 commit comments

Comments
 (0)