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
10 changes: 7 additions & 3 deletions src/js/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
125 changes: 82 additions & 43 deletions src/js/crypto.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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 };
})();
17 changes: 14 additions & 3 deletions src/js/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions src/js/fileops.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/js/home.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down Expand Up @@ -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 });
}
Expand Down
Loading