11import { createDashboardApp } from './app.js' ;
22import { readJsonAsset } from './json-assets.js' ;
3+ import { buildExportFilename , decryptBytes , decryptDashboardData , deriveAesKey , formatDelay , nextUnlockDelayMs , sha256Hex , unlockAttemptStorageKey as buildUnlockAttemptStorageKey , validateEncryptedExportManifest } from './secure-core.js' ;
34
45const app = createDashboardApp ( ) ;
56const 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 ( / \. z i p $ / i, '' )
198- . replace ( / [ ^ A - Z a - z 0 - 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 - f 0 - 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 - f 0 - 9 ] { 64 } $ / . test ( manifest . plaintext_sha256 )
291- ) {
292- throw new Error ( 'Invalid encrypted export metadata.' ) ;
293- }
294- if ( ! / ^ a s s e t s \/ e x p o r t - d a t a - [ a - f 0 - 9 ] { 16 } \. e n c $ / . 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