Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 86 additions & 29 deletions src/js/crypto.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
}

Expand All @@ -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 };
Expand Down
16 changes: 13 additions & 3 deletions src/js/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions src/js/fileops.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'); }
Expand All @@ -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));
};
Expand Down Expand Up @@ -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 <textarea> returns
const text = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
_editorOriginal = text;
Expand Down Expand Up @@ -922,6 +934,9 @@ function openViewer(node, buf, mime) {
content.innerHTML = '';
const blobObj = new Blob([buf], { type: mime }),
url = URL.createObjectURL(blobObj);
// Wipe the decrypted ArrayBuffer now that the Blob holds the data
if (buf instanceof ArrayBuffer) new Uint8Array(buf).fill(0);
else if (buf instanceof Uint8Array) buf.fill(0);
_viewerBlob = { url, node };

document.getElementById('viewer-title').textContent = node.name;
Expand Down Expand Up @@ -1327,6 +1342,10 @@ async function exportAsZip(nodeIds, zipName) {
if (!entries.length) { toast('Nothing to export', 'warn'); hideLoading(); return; }
const zipParts = _buildZip(entries);
downloadBuf(zipParts, zipName, 'application/zip');
// Wipe decrypted file data after ZIP is built
for (const e of entries) {
if (e.data instanceof Uint8Array) e.data.fill(0);
}
toast(`Exported ${entries.length} file${entries.length !== 1 ? 's' : ''} as ZIP`, 'success');
logActivity('export-zip', nodeIds.length === 1 ? (VFS.node(nodeIds[0])?.name ?? entries[0]?.name ?? '1 file') : `${entries.length} files`, entries.length, nodeIds.length === 1 ? VFS.fullPath(nodeIds[0]) : null);
} catch (e) { toast('ZIP export failed: ' + e.message, 'error'); console.error(e); }
Expand Down
37 changes: 26 additions & 11 deletions src/js/home.js
Original file line number Diff line number Diff line change
Expand Up @@ -315,11 +315,16 @@ async function doChangePassword() {
showLoading('Re-encrypting VFS…');
const vfsRec = await DB.getVFS(c.id);
if (vfsRec) {
const vfsBuf = typeof vfsRec.blob === 'string'
? await Crypto.decrypt(oldKey, vfsRec.iv, vfsRec.blob)
: await Crypto.decryptBin(oldKey, vfsRec.iv, vfsRec.blob);
const { iv: newVfsIv, blob: newVfsBlob } = await Crypto.encryptBin(newKey, vfsBuf);
await DB.saveVFS(c.id, newVfsIv, newVfsBlob);
let vfsBuf = null;
try {
vfsBuf = typeof vfsRec.blob === 'string'
? await Crypto.decrypt(oldKey, vfsRec.iv, vfsRec.blob)
: await Crypto.decryptBin(oldKey, vfsRec.iv, vfsRec.blob);
const { iv: newVfsIv, blob: newVfsBlob } = await Crypto.encryptBin(newKey, vfsBuf);
await DB.saveVFS(c.id, newVfsIv, newVfsBlob);
} finally {
if (vfsBuf) new Uint8Array(vfsBuf).fill(0);
}
}

// Expand lazy workspace (if never unlocked) so file blobs enter the re-encryption pass below.
Expand Down Expand Up @@ -350,11 +355,16 @@ async function doChangePassword() {
let _reencDone = 0;
const reencResults = await Promise.allSettled(files.map(async f => {
const buf = await Crypto.decryptBin(oldKey, f.iv, f.blob);
const { iv, blob } = await Crypto.encryptBin(newKey, buf);
_reencDone++;
if (_reencDone % 4 === 0 || _reencDone === files.length)
showLoading(`Re-encrypting files\u2026 ${_reencDone}/${files.length}`);
return { id: f.id, cid: f.cid, iv: Array.from(iv), blob };
try {
const { iv, blob } = await Crypto.encryptBin(newKey, buf);
_reencDone++;
if (_reencDone % 4 === 0 || _reencDone === files.length)
showLoading(`Re-encrypting files\u2026 ${_reencDone}/${files.length}`);
return { id: f.id, cid: f.cid, iv: Array.from(iv), blob };
} finally {
// Wipe decrypted plaintext buffer
new Uint8Array(buf).fill(0);
}
}));
const reencFiles = reencResults
.filter(r => r.status === 'fulfilled')
Expand Down Expand Up @@ -817,6 +827,8 @@ async function doUnlock() {
// Checkbox unchecked — clear any previously saved session
clearSession(c.id);
}
// Wipe raw key bytes — CryptoKey (App.key) holds it internally
_sessionRawKey.fill(0);
} catch (e) {
document.getElementById('unlock-error').innerHTML = '<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM7.25 5h1.5v4h-1.5V5zm0 5h1.5v1.5h-1.5V10z" fill="currentColor"/></svg> ' + escHtml(e.message);
console.error(e);
Expand Down Expand Up @@ -857,7 +869,10 @@ async function deleteContainerConfirmed() {
async function _resumeSession(c, rawKeyBytes) {
showLoading('Restoring session...');
try {
const key = await Crypto.importRawKey(rawKeyBytes),
// Wipe raw key bytes after import — CryptoKey will hold the key internally
const key = await Crypto.importRawKey(rawKeyBytes);
rawKeyBytes.fill(0);
const
ok = await Crypto.checkVerification(key, c.verIv, c.verBlob);
if (!ok) {
// Stored session is invalid (password changed?) — clear it and open unlock view
Expand Down
Loading