From 86fc568bdadba52cd9a8b3163d420ee5acefcef8 Mon Sep 17 00:00:00 2001 From: Zaki <18zaki18@gmail.com> Date: Fri, 8 May 2026 22:50:41 +0100 Subject: [PATCH] Fix 500 error on batch delete + add SQLite busyTimeout - Set SQLite busyTimeout(5000) to prevent 'database is locked' on concurrent requests - Await all loadEntries() in mutation functions to eliminate race conditions - Remove redundant loadEntries() from batch operations --- api.php | 73 ++++++++++++++++++++++++++++++++++++++++--------------- js/app.js | 69 ++++++++++++++++++++++++---------------------------- 2 files changed, 85 insertions(+), 57 deletions(-) diff --git a/api.php b/api.php index 1af17e9..608e500 100644 --- a/api.php +++ b/api.php @@ -18,6 +18,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') exit(0); $db_path = __DIR__ . '/vault.db'; $db = new SQLite3($db_path); $db->enableExceptions(true); +$db->busyTimeout(5000); $db->exec(" CREATE TABLE IF NOT EXISTS users ( @@ -25,7 +26,6 @@ $db->exec(" username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, salt TEXT NOT NULL, - encryption_key TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS folders ( @@ -50,12 +50,23 @@ $db->exec(" created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); + CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + token_hash TEXT UNIQUE NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); "); try { $db->exec("ALTER TABLE vault_entries ADD COLUMN encryption_method TEXT DEFAULT 'server'"); } catch (Exception $e) {} try { $db->exec("ALTER TABLE vault_entries ADD COLUMN folder TEXT DEFAULT 'All'"); } catch (Exception $e) {} try { $db->exec("ALTER TABLE vault_entries ADD COLUMN deleted INTEGER DEFAULT 0"); } catch (Exception $e) {} try { $db->exec("ALTER TABLE vault_entries ADD COLUMN deleted_at DATETIME"); } catch (Exception $e) {} +try { $db->exec("ALTER TABLE users DROP COLUMN encryption_key"); } catch (Exception $e) {} + +$db->exec("DELETE FROM sessions WHERE expires_at < datetime('now')"); $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $path = str_replace('/password-manager/api.php', '', $path); @@ -63,23 +74,22 @@ $path = str_replace('/api.php', '', $path); $method = $_SERVER['REQUEST_METHOD']; $input = json_decode(file_get_contents('php://input'), true) ?? []; -function genKey($pwd, $salt) { return hash_pbkdf2('sha256', $pwd, $salt, 100000, 32, true); } - function authenticate($db) { $headers = getallheaders(); $token = str_replace('Bearer ', '', $headers['Authorization'] ?? ''); if (!$token) { http_response_code(401); echo json_encode(['error'=>'No token']); exit; } - $decoded = base64_decode($token); - if (!$decoded) { http_response_code(401); echo json_encode(['error'=>'Bad token']); exit; } - $parts = explode(':', $decoded); - if (count($parts) !== 3) { http_response_code(401); echo json_encode(['error'=>'Token format']); exit; } - $uid = intval($parts[0]); - $key = base64_decode($parts[2]); - $st = $db->prepare('SELECT id, encryption_key FROM users WHERE id=:id'); - $st->bindValue(':id', $uid, SQLITE3_INTEGER); - $user = $st->execute()->fetchArray(SQLITE3_ASSOC); - if (!$user || $user['encryption_key'] !== base64_encode($key)) { http_response_code(401); echo json_encode(['error'=>'Invalid session']); exit; } - return ['userId' => $uid]; + $tokenHash = hash('sha256', $token); + $st = $db->prepare('SELECT user_id, expires_at FROM sessions WHERE token_hash=:th'); + $st->bindValue(':th', $tokenHash, SQLITE3_TEXT); + $session = $st->execute()->fetchArray(SQLITE3_ASSOC); + if (!$session) { http_response_code(401); echo json_encode(['error'=>'Invalid session']); exit; } + if (strtotime($session['expires_at']) < time()) { + $del = $db->prepare('DELETE FROM sessions WHERE token_hash=:th'); + $del->bindValue(':th', $tokenHash, SQLITE3_TEXT); + $del->execute(); + http_response_code(401); echo json_encode(['error'=>'Session expired']); exit; + } + return ['userId' => (int)$session['user_id']]; } try { @@ -93,12 +103,10 @@ try { if ($st->execute()->fetchArray()) { http_response_code(409); echo json_encode(['error'=>'Username exists']); break; } $salt = bin2hex(random_bytes(32)); $hash = hash_pbkdf2('sha256', $p, $salt, 100000); - $key = genKey($p, $salt); - $st = $db->prepare('INSERT INTO users (username, password_hash, salt, encryption_key) VALUES (:u, :h, :s, :k)'); + $st = $db->prepare('INSERT INTO users (username, password_hash, salt) VALUES (:u, :h, :s)'); $st->bindValue(':u', $u, SQLITE3_TEXT); $st->bindValue(':h', $hash, SQLITE3_TEXT); $st->bindValue(':s', $salt, SQLITE3_TEXT); - $st->bindValue(':k', base64_encode($key), SQLITE3_TEXT); $st->execute(); $uid = $db->lastInsertRowID(); $defaultFolders = ['All', 'Social', 'Banking', 'Work', 'Personal']; @@ -108,7 +116,14 @@ try { $stFolder->bindValue(':name', $name, SQLITE3_TEXT); $stFolder->execute(); } - $token = base64_encode($uid . ':' . bin2hex(random_bytes(16)) . ':' . base64_encode($key)); + $token = bin2hex(random_bytes(32)); + $tokenHash = hash('sha256', $token); + $expires = date('Y-m-d H:i:s', strtotime('+24 hours')); + $stS = $db->prepare('INSERT INTO sessions (user_id, token_hash, expires_at) VALUES (:uid, :th, :exp)'); + $stS->bindValue(':uid', $uid, SQLITE3_INTEGER); + $stS->bindValue(':th', $tokenHash, SQLITE3_TEXT); + $stS->bindValue(':exp', $expires, SQLITE3_TEXT); + $stS->execute(); echo json_encode(['message'=>'OK','token'=>$token,'userId'=>$uid,'salt'=>$salt]); break; @@ -122,8 +137,6 @@ try { if (!hash_equals($user['password_hash'], hash_pbkdf2('sha256', $p, $user['salt'], 100000))) { http_response_code(401); echo json_encode(['error'=>'Invalid credentials']); break; } - $key = genKey($p, $user['salt']); - $token = base64_encode($user['id'] . ':' . bin2hex(random_bytes(16)) . ':' . base64_encode($key)); $defaultFolders = ['All', 'Social', 'Banking', 'Work', 'Personal']; $stFolder = $db->prepare('INSERT OR IGNORE INTO folders (user_id, name) VALUES (:uid, :name)'); foreach ($defaultFolders as $name) { @@ -131,6 +144,14 @@ try { $stFolder->bindValue(':name', $name, SQLITE3_TEXT); $stFolder->execute(); } + $token = bin2hex(random_bytes(32)); + $tokenHash = hash('sha256', $token); + $expires = date('Y-m-d H:i:s', strtotime('+24 hours')); + $stS = $db->prepare('INSERT INTO sessions (user_id, token_hash, expires_at) VALUES (:uid, :th, :exp)'); + $stS->bindValue(':uid', $user['id'], SQLITE3_INTEGER); + $stS->bindValue(':th', $tokenHash, SQLITE3_TEXT); + $stS->bindValue(':exp', $expires, SQLITE3_TEXT); + $stS->execute(); echo json_encode(['message'=>'OK','token'=>$token,'userId'=>$user['id'],'salt'=>$user['salt']]); break; @@ -275,6 +296,18 @@ try { break; // Empty trash (permanently delete all soft-deleted) + case ($path === '/logout' && $method === 'POST'): + $headers = getallheaders(); + $token = str_replace('Bearer ', '', $headers['Authorization'] ?? ''); + if ($token) { + $tokenHash = hash('sha256', $token); + $del = $db->prepare('DELETE FROM sessions WHERE token_hash=:th'); + $del->bindValue(':th', $tokenHash, SQLITE3_TEXT); + $del->execute(); + } + echo json_encode(['message'=>'Logged out']); + break; + case ($path === '/entries/trash/empty' && $method === 'DELETE'): $auth = authenticate($db); $st = $db->prepare('DELETE FROM vault_entries WHERE user_id=:uid AND deleted=1'); diff --git a/js/app.js b/js/app.js index 69e46bb..d2bc78c 100644 --- a/js/app.js +++ b/js/app.js @@ -118,9 +118,11 @@ function toggleTheme() { dark = !dark; localStorage.setItem('darkTheme', dark); // ==================== CRYPTO ==================== function checkStrength() { const p = document.getElementById('passwordInput').value; const b = document.getElementById('strengthBar'); let s = 0; if (p.length >= 8) s++; if (p.length >= 12) s++; if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s++; if (/\d/.test(p)) s++; if (/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(p)) s++; b.className = 'strength-bar s' + Math.min(4, s); } -async function deriveKey(pwd, salt) { const enc = new TextEncoder(); const km = await crypto.subtle.importKey('raw', enc.encode(pwd), 'PBKDF2', false, ['deriveKey']); const sb = Uint8Array.from(atob(salt), c => c.charCodeAt(0)); return crypto.subtle.deriveKey({ name: 'PBKDF2', salt: sb, iterations: 100000, hash: 'SHA-256' }, km, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); } +async function deriveKey(pwd, salt) { const enc = new TextEncoder(); const km = await crypto.subtle.importKey('raw', enc.encode(pwd), 'PBKDF2', false, ['deriveKey']); const sb = Uint8Array.from(atob(salt), c => c.charCodeAt(0)); return crypto.subtle.deriveKey({ name: 'PBKDF2', salt: sb, iterations: 100000, hash: 'SHA-256' }, km, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); } async function encryptPwd(plain) { const iv = crypto.getRandomValues(new Uint8Array(12)); const enc = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, new TextEncoder().encode(plain)); return { encrypted: btoa(String.fromCharCode(...new Uint8Array(enc))), iv: btoa(String.fromCharCode(...iv)) }; } async function decryptPwd(encB64, ivB64) { try { const enc = Uint8Array.from(atob(encB64), c => c.charCodeAt(0)); const iv = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0)); const dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, cryptoKey, enc); return new TextDecoder().decode(dec); } catch (e) { return '[ERROR]'; } } +async function persistCryptoKey() { const raw = await crypto.subtle.exportKey('raw', cryptoKey); sessionStorage.setItem('cryptoKey', btoa(String.fromCharCode(...new Uint8Array(raw)))); } +async function restoreCryptoKey() { const saved = sessionStorage.getItem('cryptoKey'); if (!saved) return false; try { const raw = Uint8Array.from(atob(saved), c => c.charCodeAt(0)); cryptoKey = await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); return true; } catch (e) { return false; } } // ==================== AUTO-LOCK ==================== function setAutoLock() { lockMin = parseInt(document.getElementById('autoLockTimer').value); localStorage.setItem('autoLockMinutes', lockMin); resetIdle(); } @@ -249,15 +251,15 @@ function toggleTrash() { playSound('click'); } async function restoreEntry(id) { - try { const r = await fetch(API + '/entries/' + id + '/restore', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token } }); if (r.ok) { toast('✅ Restored!'); loadEntries(); playSound('success'); } } catch (e) { toast('⚠️ Error', 'error'); } + try { const r = await fetch(API + '/entries/' + id + '/restore', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token } }); if (r.ok) { toast('✅ Restored!'); await loadEntries(); playSound('success'); } } catch (e) { toast('⚠️ Error', 'error'); } } async function permanentDelete(id) { if (!confirm('Permanently delete?')) return; - try { const r = await fetch(API + '/entries/' + id + '?permanent=1', { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token } }); if (r.ok) { toast('🗑️ Permanently deleted'); loadEntries(); playSound('error'); } } catch (e) { toast('⚠️ Error', 'error'); } + try { const r = await fetch(API + '/entries/' + id + '?permanent=1', { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token } }); if (r.ok) { toast('🗑️ Permanently deleted'); await loadEntries(); playSound('error'); } } catch (e) { toast('⚠️ Error', 'error'); } } async function emptyTrash() { if (!confirm('Delete ALL trashed entries?')) return; - try { const r = await fetch(API + '/entries/trash/empty', { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token } }); if (r.ok) { toast('🗑️ Trash emptied'); loadEntries(); playSound('error'); } } catch (e) { toast('⚠️ Error', 'error'); } + try { const r = await fetch(API + '/entries/trash/empty', { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token } }); if (r.ok) { toast('🗑️ Trash emptied'); await loadEntries(); playSound('error'); } } catch (e) { toast('⚠️ Error', 'error'); } } function timeAgo(dateStr) { if (!dateStr) return ''; @@ -320,7 +322,7 @@ function toggleShowEmail() { showMail = !showMail; localStorage.setItem('showEma function openGen() { document.getElementById('genModal').style.display = 'flex'; genPwd(); playSound('open'); } function closeGen() { document.getElementById('genModal').style.display = 'none'; playSound('close'); } function onLenChange() { document.getElementById('lenVal').textContent = document.getElementById('pwdLen').value; genPwd(); } -function genPwd() { const l = parseInt(document.getElementById('pwdLen').value); let c = ''; if (document.getElementById('useUpper').checked) c += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; if (document.getElementById('useLower').checked) c += 'abcdefghijklmnopqrstuvwxyz'; if (document.getElementById('useNum').checked) c += '0123456789'; if (document.getElementById('useSym').checked) c += '!@#$%^&*()_+-=[]{}|;:,.<>?'; if (!c) { document.getElementById('genPreview').textContent = 'Select option'; return; } let p = ''; for (let i = 0; i < l; i++) p += c.charAt(Math.floor(Math.random() * c.length)); genPwdVal = p; document.getElementById('genPreview').textContent = p; } +function genPwd() { const l = parseInt(document.getElementById('pwdLen').value); let c = ''; if (document.getElementById('useUpper').checked) c += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; if (document.getElementById('useLower').checked) c += 'abcdefghijklmnopqrstuvwxyz'; if (document.getElementById('useNum').checked) c += '0123456789'; if (document.getElementById('useSym').checked) c += '!@#$%^&*()_+-=[]{}|;:,.<>?'; if (!c) { document.getElementById('genPreview').textContent = 'Select option'; return; } let p = ''; const arr = new Uint32Array(l); crypto.getRandomValues(arr); for (let i = 0; i < l; i++) p += c.charAt(arr[i] % c.length); genPwdVal = p; document.getElementById('genPreview').textContent = p; } function useGen() { if (!genPwdVal) genPwd(); // Put the generated password into the add‑modal’s password field @@ -388,9 +390,8 @@ async function login() { const d = await r.json(); if (r.ok) { token = d.token; curUser = u; - sessionStorage.setItem('masterPassword', p); - sessionStorage.setItem('salt', d.salt); cryptoKey = await deriveKey(p, d.salt); + persistCryptoKey(); sessionStorage.setItem('authToken', token); sessionStorage.setItem('currentUsername', u); await loadFolders(); @@ -414,9 +415,8 @@ async function register() { const d = await r.json(); if (r.ok) { token = d.token; curUser = u; - sessionStorage.setItem('masterPassword', p); - sessionStorage.setItem('salt', d.salt); cryptoKey = await deriveKey(p, d.salt); + persistCryptoKey(); sessionStorage.setItem('authToken', token); sessionStorage.setItem('currentUsername', u); await loadFolders(); @@ -429,8 +429,11 @@ async function register() { finally { document.getElementById('registerBtn').disabled = false; } } -function doLogout() { +async function doLogout() { saveUsername(); + if (token) { + try { await fetch(API + '/logout', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token } }); } catch (e) {} + } clearTimeout(idleT); clearTimeout(warnT); clearInterval(countT); document.getElementById('idleWarning').classList.remove('show'); token = null; curUser = null; entries = []; cryptoKey = null; folders = ['All']; showTrash = false; @@ -503,7 +506,7 @@ function render() { h += '