diff --git a/src/js/crypto.js b/src/js/crypto.js index 4dc2615..a6d37e6 100644 --- a/src/js/crypto.js +++ b/src/js/crypto.js @@ -5,27 +5,47 @@ ============================================================ */ const Crypto = (() => { + const IV_LENGTH = 12; + // Returns raw 32-byte Argon2id hash as Uint8Array async function deriveRaw(password, salt) { - return hashwasm.argon2id({ - password, - salt, - parallelism: ARGON2_PAR, - iterations: ARGON2_ITER, - memorySize: ARGON2_MEM, - hashLength: 32, - outputType: 'binary', - }); + // Normalize password input to Uint8Array for safe wiping + let passBytes = null; + if (password instanceof Uint8Array) { + passBytes = new Uint8Array(password); + } else if (password instanceof ArrayBuffer) { + passBytes = new Uint8Array(password.slice(0)); + } else { + passBytes = new TextEncoder().encode(String(password || '')); + } + try { + return await hashwasm.argon2id({ + password: passBytes, + salt, + parallelism: ARGON2_PAR, + iterations: ARGON2_ITER, + memorySize: ARGON2_MEM, + hashLength: 32, + outputType: 'binary', + }); + } finally { + passBytes.fill(0); + } } async function deriveKey(password, salt) { - const hash = await deriveRaw(password, salt); - return crypto.subtle.importKey( - 'raw', hash, - { name: 'AES-GCM' }, - false, - ['encrypt', 'decrypt'] - ); + let hash = null; + try { + hash = await deriveRaw(password, salt); + return await crypto.subtle.importKey( + 'raw', hash, + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'] + ); + } finally { + if (hash && typeof hash.fill === 'function') hash.fill(0); + } } // Derives both the CryptoKey and the raw bytes in a single Argon2id pass. @@ -47,29 +67,61 @@ const Crypto = (() => { } async function encrypt(key, data) { - const iv = crypto.getRandomValues(new Uint8Array(12)); - const buf = data instanceof ArrayBuffer - ? data - : (data instanceof Uint8Array + const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); + let buf = null, shouldWipeBuf = false; + if (data instanceof ArrayBuffer) buf = data; + else if (data instanceof Uint8Array) { + const fullView = data.byteOffset === 0 && data.byteLength === data.buffer.byteLength; + buf = fullView ? data.buffer - : new TextEncoder().encode(typeof data === 'string' ? data : JSON.stringify(data))); - const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, buf); - return { iv: Array.from(iv), blob: buf2b64(ct) }; + : data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); + shouldWipeBuf = !fullView; + } + else { + buf = new TextEncoder().encode(typeof data === 'string' ? data : JSON.stringify(data)); + shouldWipeBuf = true; + } + try { + const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, buf); + return { iv: Array.from(iv), blob: buf2b64(ct) }; + } finally { + if (shouldWipeBuf && buf) new Uint8Array(buf).fill(0); + } } async function decrypt(key, iv, blobB64) { + if (!Array.isArray(iv) || iv.length !== IV_LENGTH) throw new Error('Invalid IV'); const ivU8 = new Uint8Array(iv), buf = b642buf(blobB64); - return crypto.subtle.decrypt({ name: 'AES-GCM', iv: ivU8 }, key, buf); + try { + return await crypto.subtle.decrypt({ name: 'AES-GCM', iv: ivU8 }, key, buf); + } finally { + new Uint8Array(buf).fill(0); + } } async function encryptBin(key, buf) { - const iv = crypto.getRandomValues(new Uint8Array(12)), - ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, buf); - return { iv: Array.from(iv), blob: ct }; + const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); + if (!(buf instanceof ArrayBuffer) && !(buf instanceof Uint8Array)) { + throw new Error('Invalid plaintext buffer'); + } + let copied = null; + const input = buf instanceof Uint8Array + ? (buf.byteOffset === 0 && buf.byteLength === buf.buffer.byteLength + ? buf.buffer + : (copied = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength))) + : buf; + try { + const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, input); + return { iv: Array.from(iv), blob: ct }; + } finally { + if (copied) new Uint8Array(copied).fill(0); + } } async function decryptBin(key, iv, blob) { + if (!iv || iv.length !== IV_LENGTH) throw new Error('Invalid IV'); + if (!(blob instanceof ArrayBuffer) && !(blob instanceof Uint8Array)) throw new Error('Invalid ciphertext buffer'); return crypto.subtle.decrypt({ name: 'AES-GCM', iv: new Uint8Array(iv) }, key, blob); } @@ -79,10 +131,15 @@ const Crypto = (() => { } async function checkVerification(key, iv, blob) { + let buf = null; try { - const buf = await decrypt(key, iv, blob); + buf = await decrypt(key, iv, blob); return new TextDecoder().decode(buf) === VERIFY_TEXT; - } catch { return false; } + } catch { + return false; + } finally { + if (buf) new Uint8Array(buf).fill(0); + } } return { deriveRaw, deriveKey, deriveKeyAndRaw, importRawKey, encrypt, decrypt, encryptBin, decryptBin, makeVerification, checkVerification }; diff --git a/src/js/db.js b/src/js/db.js index 8be2302..a0af534 100644 --- a/src/js/db.js +++ b/src/js/db.js @@ -37,7 +37,14 @@ const DB = (() => { InitLog.done('DB schema upgrade'); } catch (err) { InitLog.error('DB schema upgrade', err); fail(err); } }; - req.onsuccess = e => done(e.target.result); + req.onsuccess = e => { + const db = e.target.result; + db.onversionchange = () => { + try { db.close(); } catch { } + _db = null; + }; + done(db); + }; req.onerror = () => fail(req.error); req.onblocked = () => { // Another connection prevents upgrade; close it by requesting versionchange on self @@ -48,8 +55,11 @@ const DB = (() => { }); } - function rw(store) { return _db.transaction(store, 'readwrite').objectStore(store); } - function ro(store) { return _db.transaction(store, 'readonly').objectStore(store); } + function _ensureDb() { + if (!_db) throw new Error('Database is not initialized'); + } + function rw(store) { _ensureDb(); return _db.transaction(store, 'readwrite').objectStore(store); } + function ro(store) { _ensureDb(); return _db.transaction(store, 'readonly').objectStore(store); } function wrap(req) { return new Promise((r, j) => { req.onsuccess = () => r(req.result); req.onerror = () => j(req.error); }); } // Reassemble a chunked file record: reads N chunks from 'chunks' store, diff --git a/src/js/fileops.js b/src/js/fileops.js index fea42ea..e1a6655 100644 --- a/src/js/fileops.js +++ b/src/js/fileops.js @@ -51,6 +51,8 @@ async function uploadFiles(files) { mime = f.type || getMime(name), { iv, blob } = await Crypto.encryptBin(App.key, bufs[bi]), nodeId = uid(); + // Wipe plaintext buffer after encryption + new Uint8Array(bufs[bi]).fill(0); VFS.add({ id: nodeId, type: 'file', name, mime, size: f.size, parentId: App.folder, ctime: Date.now(), mtime: Date.now() @@ -114,6 +116,8 @@ async function _uploadFileEntry(fileEntry, targetFolderId) { mime = file.type || getMime(name), { iv, blob } = await Crypto.encryptBin(App.key, buf), nodeId = uid(), now = Date.now(); + // Wipe plaintext buffer after encryption + new Uint8Array(buf).fill(0); VFS.add({ id: nodeId, type: 'file', name, mime, size: file.size, parentId: targetFolderId, ctime: now, mtime: now @@ -234,6 +238,8 @@ async function downloadFile(node) { if (!rec) { toast('File data not found', 'error'); hideLoading(); return; } const buf = await Crypto.decryptBin(App.key, rec.iv, rec.blob); downloadBuf(buf, node.name, node.mime || getMime(node.name)); + // Wipe decrypted plaintext after download is initiated + new Uint8Array(buf).fill(0); toast('Exported: ' + node.name, 'success'); logActivity('download', node.name, 1, VFS.fullPath(node.id)); } catch (e) { toast('Decryption failed: ' + e.message, 'error'); } @@ -255,6 +261,9 @@ function _confirmExport(node, buf, mime) { document.getElementById('ec-ok').onclick = () => { Overlay.hide(); downloadBuf(buf, node.name, mime); + // Wipe decrypted plaintext after download is initiated + if (buf instanceof ArrayBuffer) new Uint8Array(buf).fill(0); + else if (buf instanceof Uint8Array) buf.fill(0); toast('Exported: ' + node.name, 'success'); logActivity('download', node.name, 1, VFS.fullPath(node.id)); }; @@ -789,6 +798,9 @@ let _editorOriginal = ''; function openEditor(node, buf) { _editorNode = node; const raw = new TextDecoder().decode(buf); + // Wipe the decrypted ArrayBuffer now that we have the string + if (buf instanceof ArrayBuffer) new Uint8Array(buf).fill(0); + else if (buf instanceof Uint8Array) buf.fill(0); // Normalize line endings to \n to match what