diff --git a/src/js/constants.js b/src/js/constants.js index 828c1d8..7b7dbcb 100644 --- a/src/js/constants.js +++ b/src/js/constants.js @@ -85,10 +85,14 @@ function buf2b64(buf) { for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); return btoa(s); } -function b642buf(s) { - const b = atob(s), u = new Uint8Array(b.length); +function b642u8(s) { + const b = atob(String(s || '')); + const u = new Uint8Array(b.length); for (let i = 0; i < b.length; i++) u[i] = b.charCodeAt(i); - return u.buffer; + return u; +} +function b642buf(s) { + return b642u8(s).buffer; } function pwStrength(pw) { diff --git a/src/js/crypto.js b/src/js/crypto.js index 68382e2..94e12e2 100644 --- a/src/js/crypto.js +++ b/src/js/crypto.js @@ -5,63 +5,97 @@ ============================================================ */ const Crypto = (() => { - // 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', - }); - } + const IV_LENGTH = 12; async function deriveKey(password, salt) { - const hash = await deriveRaw(password, salt); - return crypto.subtle.importKey( - 'raw', hash, - { name: 'AES-GCM' }, - false, - ['encrypt', 'decrypt'] - ); - } - - // Import a pre-derived 32-byte key (skips Argon2id for session resume) - async function importRawKey(rawBytes) { - return crypto.subtle.importKey( - 'raw', rawBytes, - { name: 'AES-GCM' }, - false, - ['encrypt', 'decrypt'] - ); + let hash = null; + 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 { + hash = await hashwasm.argon2id({ + password: passBytes, + salt, + parallelism: ARGON2_PAR, + iterations: ARGON2_ITER, + memorySize: ARGON2_MEM, + hashLength: 32, + outputType: 'binary', + }); + return await crypto.subtle.importKey( + 'raw', hash, + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'] + ); + } finally { + passBytes.fill(0); + if (hash && typeof hash.fill === 'function') hash.fill(0); + } } 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); const 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 { + const u8 = new Uint8Array(buf); + u8.fill(0); + } } async function encryptBin(key, buf) { - const iv = crypto.getRandomValues(new Uint8Array(12)); - const 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); } @@ -71,11 +105,16 @@ 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, importRawKey, encrypt, decrypt, encryptBin, decryptBin, makeVerification, checkVerification }; + return { deriveKey, encrypt, decrypt, encryptBin, decryptBin, makeVerification, checkVerification }; })(); diff --git a/src/js/db.js b/src/js/db.js index 69585e5..430822c 100644 --- a/src/js/db.js +++ b/src/js/db.js @@ -22,13 +22,24 @@ const DB = (() => { db.createObjectStore('vfs', { keyPath: 'cid' }); } }; - req.onsuccess = e => { _db = e.target.result; res(); }; + req.onsuccess = e => { + _db = e.target.result; + _db.onversionchange = () => { + try { _db.close(); } catch { } + _db = null; + }; + res(); + }; + req.onblocked = () => rej(new Error('Database upgrade blocked by another open tab')); req.onerror = () => rej(req.error); }); } - 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); }); } return { diff --git a/src/js/fileops.js b/src/js/fileops.js index 42beaa0..d04b50a 100644 --- a/src/js/fileops.js +++ b/src/js/fileops.js @@ -1300,8 +1300,8 @@ async function importContainerFile(file) { if (!nameRaw || !saltB64 || !verIvB64 || !verBlob) throw new Error('Invalid container.xml: missing required fields'); - const salt = Array.from(Uint8Array.from(atob(saltB64), ch => ch.charCodeAt(0))), - verIv = Array.from(Uint8Array.from(atob(verIvB64), ch => ch.charCodeAt(0))); + const salt = Array.from(b642u8(saltB64)), + verIv = Array.from(b642u8(verIvB64)); if (entries['meta/2'] && entries['meta/3']) { // v3: import without password — encrypted workspace stored as-is, expanded on first unlock diff --git a/src/js/home.js b/src/js/home.js index 44dd19d..1c7b056 100644 --- a/src/js/home.js +++ b/src/js/home.js @@ -697,7 +697,7 @@ async function doUnlock() { const decBuf = await Crypto.decrypt(key, Array.from(mIv), buf2b64(mBlob)); const manifest = JSON.parse(new TextDecoder().decode(decBuf)); for (const m of manifest) { - const iv = Array.from(Uint8Array.from(atob(m.ivB64), ch => ch.charCodeAt(0))), + const iv = Array.from(b642u8(m.ivB64)), blob = bin.slice(m.offset, m.offset + m.size).buffer; await DB.saveFile({ id: m.id, cid: c.id, iv, blob }); } @@ -801,7 +801,7 @@ async function _resumeSession(c, rawKeyBytes) { const decBuf = await Crypto.decrypt(key, Array.from(mIv), buf2b64(mBlob)); const manifest = JSON.parse(new TextDecoder().decode(decBuf)); for (const m of manifest) { - const iv = Array.from(Uint8Array.from(atob(m.ivB64), ch => ch.charCodeAt(0))), + const iv = Array.from(b642u8(m.ivB64)), blob = bin.slice(m.offset, m.offset + m.size).buffer; await DB.saveFile({ id: m.id, cid: c.id, iv, blob }); } diff --git a/src/js/state.js b/src/js/state.js index 557fd63..1089386 100644 --- a/src/js/state.js +++ b/src/js/state.js @@ -15,72 +15,112 @@ ============================================================ */ let _sessionKey = null; +function _hasAnySavedSessions() { + for (let i = 0; i < sessionStorage.length; i++) { + const k = sessionStorage.key(i); + if (k && k.startsWith('snv-s-')) return true; + } + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k && k.startsWith('snv-sb-')) return true; + } + return false; +} + +function _dropSessionKeyIfUnused() { + if (_hasAnySavedSessions()) return; + _sessionKey = null; + sessionStorage.removeItem('snv-sk'); +} + async function _getOrCreateSessionKey() { if (_sessionKey) return _sessionKey; const stored = sessionStorage.getItem('snv-sk'); if (stored) { try { - const raw = Uint8Array.from(atob(stored), ch => ch.charCodeAt(0)); + const raw = b642u8(stored); _sessionKey = await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); + raw.fill(0); return _sessionKey; } catch { /* corrupted — regenerate below */ } } const raw = crypto.getRandomValues(new Uint8Array(32)); - const exp = await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']); - const exported = await crypto.subtle.exportKey('raw', exp); - sessionStorage.setItem('snv-sk', btoa(String.fromCharCode(...new Uint8Array(exported)))); - // Re-import as non-extractable for forward secrecy within this session - _sessionKey = await crypto.subtle.importKey('raw', exported, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); - return _sessionKey; + let exportedU8 = null; + try { + const exp = await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']); + const exported = await crypto.subtle.exportKey('raw', exp); + exportedU8 = new Uint8Array(exported); + sessionStorage.setItem('snv-sk', buf2b64(exportedU8)); + // Re-import as non-extractable for forward secrecy within this session + _sessionKey = await crypto.subtle.importKey('raw', exported, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); + return _sessionKey; + } finally { + raw.fill(0); + if (exportedU8) exportedU8.fill(0); + } } -// Browser-scope sessions expire after 7 days; tab-scope persist until tab closes -const SESSION_TTL_BROWSER = 7 * 24 * 60 * 60 * 1000; - -// rawKeyBytes — Uint8Array(32) from Crypto.deriveRaw(), never the plaintext password -async function saveSession(cid, rawKeyBytes, scope) { +async function saveSession(cid, password, scope) { const key = await _getOrCreateSessionKey(), iv = crypto.getRandomValues(new Uint8Array(12)); - const expiryMs = scope === 'browser' ? Date.now() + SESSION_TTL_BROWSER : Number.MAX_SAFE_INTEGER; - const payload = new Uint8Array(8 + rawKeyBytes.length); - new DataView(payload.buffer).setBigUint64(0, BigInt(expiryMs), true); - payload.set(rawKeyBytes, 8); - const aad = new TextEncoder().encode('snv-session:' + cid); - const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: aad }, key, payload); - const blob = new Uint8Array(12 + ct.byteLength); - blob.set(iv); - blob.set(new Uint8Array(ct), 12); - const b64 = btoa(String.fromCharCode(...blob)); - if (scope === 'browser') { - localStorage.setItem('snv-sb-' + cid, b64); - sessionStorage.removeItem('snv-s-' + cid); - } else { - sessionStorage.setItem('snv-s-' + cid, b64); - localStorage.removeItem('snv-sb-' + cid); + const pwBytes = new TextEncoder().encode(password || ''); + let ctU8 = null; + let blob = null; + try { + const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, pwBytes); + blob = new Uint8Array(12 + ct.byteLength); + ctU8 = new Uint8Array(ct); + blob.set(iv, 0); + blob.set(ctU8, 12); + const b64 = buf2b64(blob); + if (scope === 'browser') { + localStorage.setItem('snv-sb-' + cid, b64); + sessionStorage.removeItem('snv-s-' + cid); + } else { + sessionStorage.setItem('snv-s-' + cid, b64); + localStorage.removeItem('snv-sb-' + cid); + } + } finally { + pwBytes.fill(0); + if (ctU8) ctU8.fill(0); + if (blob) blob.fill(0); } } -// Returns Uint8Array(32) raw key bytes on success, or null on failure/expiry async function loadSession(cid) { + return null; +} + +async function loadSessionKey(cid, salt) { const b64 = sessionStorage.getItem('snv-s-' + cid) || localStorage.getItem('snv-sb-' + cid); if (!b64) return null; + let blob = null; + let iv = null; + let ct = null; + let dec = null; + let decU8 = null; try { - const key = await _getOrCreateSessionKey(), - blob = Uint8Array.from(atob(b64), ch => ch.charCodeAt(0)), - iv = blob.slice(0, 12), - ct = blob.slice(12), - aad = new TextEncoder().encode('snv-session:' + cid), - dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv, additionalData: aad }, key, ct); - const payload = new Uint8Array(dec); - const expiry = Number(new DataView(payload.buffer).getBigUint64(0, true)); - if (Date.now() > expiry) { clearSession(cid); return null; } - return payload.slice(8); // 32-byte raw key material - } catch { clearSession(cid); return null; } + const key = await _getOrCreateSessionKey(); + blob = b642u8(b64); + iv = blob.slice(0, 12); + ct = blob.slice(12); + dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct); + decU8 = new Uint8Array(dec); + return await Crypto.deriveKey(decU8, new Uint8Array(salt || [])); + } catch { + return null; + } finally { + if (decU8) decU8.fill(0); + if (blob) blob.fill(0); + if (iv) iv.fill(0); + if (ct) ct.fill(0); + } } function clearSession(cid) { sessionStorage.removeItem('snv-s-' + cid); localStorage.removeItem('snv-sb-' + cid); + _dropSessionKeyIfUnused(); } function hasSession(cid) { @@ -137,6 +177,11 @@ const App = { // Return to home WITHOUT killing the session (password stays remembered) async backToMenu() { this.key = null; + if (this.container) { + for (let k of Object.keys(this.container)) { + this.container[k] = null; + } + } this.container = null; this.folder = 'root'; this.selection = new Set(); @@ -159,6 +204,14 @@ const App = { const cid = this.container?.id; if (cid) clearSession(cid); this.key = null; + + // Paranoid: empty the container object completely before releasing it to GC + if (this.container) { + for (let k of Object.keys(this.container)) { + this.container[k] = null; + } + } + this.container = null; this.folder = 'root'; this.selection = new Set();