From c8c7b69ae0b9b148afc0a2df1768457b3eb9b5ea Mon Sep 17 00:00:00 2001 From: Zaki <18zaki18@gmail.com> Date: Fri, 8 May 2026 23:14:07 +0100 Subject: [PATCH] Phase 5: CSRF, bcrypt hashing, audit logging, export re-auth - Add CSRF token per session, validated on all state-changing requests (POST/PUT/DELETE) - Upgrade password hashing from PBKDF2 to bcrypt; auto-upgrade old hashes on login - Add audit_log table tracking all security events (login, export, delete, etc.) - Add /reauth endpoint requiring master password before export - Client-side: re-auth modal before export, X-CSRF-Token header on mutations --- api.php | 166 +++++++++++++++++++++++++++++++++++++++++++++++------ index.html | 2 +- js/app.js | 63 +++++++++++++++----- 3 files changed, 200 insertions(+), 31 deletions(-) diff --git a/api.php b/api.php index 608e500..8a097b9 100644 --- a/api.php +++ b/api.php @@ -1,17 +1,26 @@ getMessage() . PHP_EOL, FILE_APPEND); http_response_code(500); header('Content-Type: application/json'); - echo json_encode(['error' => 'Server error: ' . $e->getMessage()]); + echo json_encode(['error' => 'Internal server error']); exit; }); header('Content-Type: application/json'); -header('Access-Control-Allow-Origin: *'); -header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); -header('Access-Control-Allow-Headers: Content-Type, Authorization'); +header('Strict-Transport-Security: max-age=31536000; includeSubDomains'); + +$origin = $_SERVER['HTTP_ORIGIN'] ?? ''; +if ($origin && preg_match('#^https?://(localhost|127\.0\.0\.1)(:\d+)?$#', $origin)) { + header('Access-Control-Allow-Origin: ' . $origin); + header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); + header('Access-Control-Allow-Headers: Content-Type, Authorization'); +} if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') exit(0); @@ -58,6 +67,18 @@ $db->exec(" expires_at DATETIME NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS login_attempts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip TEXT NOT NULL, + attempted_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + action TEXT NOT NULL, + ip TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); "); try { $db->exec("ALTER TABLE vault_entries ADD COLUMN encryption_method TEXT DEFAULT 'server'"); } catch (Exception $e) {} @@ -65,8 +86,63 @@ try { $db->exec("ALTER TABLE vault_entries ADD COLUMN folder TEXT DEFAULT 'All'" 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) {} +try { $db->exec("ALTER TABLE users ADD COLUMN hash_algo TEXT DEFAULT 'pbkdf2'"); } catch (Exception $e) {} +try { $db->exec("ALTER TABLE sessions ADD COLUMN csrf_token TEXT"); } catch (Exception $e) {} $db->exec("DELETE FROM sessions WHERE expires_at < datetime('now')"); +$db->exec("DELETE FROM login_attempts WHERE attempted_at < datetime('now', '-15 minutes')"); +$db->exec("DELETE FROM audit_log WHERE created_at < datetime('now', '-30 days')"); + +function getClientIP() { + $headers = getallheaders(); + return $headers['X-Forwarded-For'] ?? $_SERVER['REMOTE_ADDR'] ?? 'unknown'; +} + +function checkRateLimit($db) { + $ip = getClientIP(); + $window = gmdate('Y-m-d H:i:s', strtotime('-15 minutes')); + $st = $db->prepare('SELECT COUNT(*) as cnt FROM login_attempts WHERE ip=:ip AND attempted_at > :window'); + $st->bindValue(':ip', $ip, SQLITE3_TEXT); + $st->bindValue(':window', $window, SQLITE3_TEXT); + $row = $st->execute()->fetchArray(SQLITE3_ASSOC); + return (int)($row['cnt'] ?? 0); +} + +function recordAttempt($db) { + $ip = getClientIP(); + $st = $db->prepare('INSERT INTO login_attempts (ip) VALUES (:ip)'); + $st->bindValue(':ip', $ip, SQLITE3_TEXT); + $st->execute(); +} + +function clearAttempts($db) { + $ip = getClientIP(); + $st = $db->prepare('DELETE FROM login_attempts WHERE ip=:ip'); + $st->bindValue(':ip', $ip, SQLITE3_TEXT); + $st->execute(); +} + +function logAudit($db, $userId, $action) { + $ip = getClientIP(); + $st = $db->prepare('INSERT INTO audit_log (user_id, action, ip) VALUES (:uid, :action, :ip)'); + $st->bindValue(':uid', $userId, SQLITE3_INTEGER); + $st->bindValue(':action', $action, SQLITE3_TEXT); + $st->bindValue(':ip', $ip, SQLITE3_TEXT); + $st->execute(); +} + +function requireCSRF($db, $userId) { + if ($_SERVER['REQUEST_METHOD'] === 'GET') return; + $headers = getallheaders(); + $csrfToken = $headers['X-CSRF-Token'] ?? ''; + if (!$csrfToken) { http_response_code(403); echo json_encode(['error'=>'Missing CSRF token']); exit; } + $st = $db->prepare('SELECT csrf_token FROM sessions WHERE user_id=:uid AND expires_at > datetime(\'now\') ORDER BY created_at DESC LIMIT 1'); + $st->bindValue(':uid', $userId, SQLITE3_INTEGER); + $row = $st->execute()->fetchArray(SQLITE3_ASSOC); + if (!$row || !hash_equals($row['csrf_token'], $csrfToken)) { + http_response_code(403); echo json_encode(['error'=>'Invalid CSRF token']); exit; + } +} $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $path = str_replace('/password-manager/api.php', '', $path); @@ -95,6 +171,7 @@ function authenticate($db) { try { switch (true) { case ($path === '/register' && $method === 'POST'): + if (checkRateLimit($db) >= 5) { http_response_code(429); echo json_encode(['error'=>'Too many attempts. Try again later.']); break; } $u = trim($input['username'] ?? ''); $p = $input['masterPassword'] ?? ''; if (strlen($u) < 3 || strlen($p) < 8) { http_response_code(400); echo json_encode(['error'=>'Min 3/8 chars']); break; } @@ -102,11 +179,12 @@ try { $st->bindValue(':u', $u, SQLITE3_TEXT); 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); - $st = $db->prepare('INSERT INTO users (username, password_hash, salt) VALUES (:u, :h, :s)'); + $hash = password_hash($p, PASSWORD_BCRYPT); + $st = $db->prepare('INSERT INTO users (username, password_hash, salt, hash_algo) VALUES (:u, :h, :s, :algo)'); $st->bindValue(':u', $u, SQLITE3_TEXT); $st->bindValue(':h', $hash, SQLITE3_TEXT); $st->bindValue(':s', $salt, SQLITE3_TEXT); + $st->bindValue(':algo', 'bcrypt', SQLITE3_TEXT); $st->execute(); $uid = $db->lastInsertRowID(); $defaultFolders = ['All', 'Social', 'Banking', 'Work', 'Personal']; @@ -118,25 +196,42 @@ try { } $token = bin2hex(random_bytes(32)); $tokenHash = hash('sha256', $token); + $csrfToken = bin2hex(random_bytes(32)); $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 = $db->prepare('INSERT INTO sessions (user_id, token_hash, csrf_token, expires_at) VALUES (:uid, :th, :csrf, :exp)'); $stS->bindValue(':uid', $uid, SQLITE3_INTEGER); $stS->bindValue(':th', $tokenHash, SQLITE3_TEXT); + $stS->bindValue(':csrf', $csrfToken, SQLITE3_TEXT); $stS->bindValue(':exp', $expires, SQLITE3_TEXT); $stS->execute(); - echo json_encode(['message'=>'OK','token'=>$token,'userId'=>$uid,'salt'=>$salt]); + logAudit($db, $uid, 'register'); + echo json_encode(['message'=>'OK','token'=>$token,'userId'=>$uid,'salt'=>$salt,'csrfToken'=>$csrfToken]); break; case ($path === '/login' && $method === 'POST'): + if (checkRateLimit($db) >= 10) { http_response_code(429); echo json_encode(['error'=>'Too many attempts. Try again later.']); break; } $u = trim($input['username'] ?? ''); $p = $input['masterPassword'] ?? ''; $st = $db->prepare('SELECT * FROM users WHERE username=:u'); $st->bindValue(':u', $u, SQLITE3_TEXT); $user = $st->execute()->fetchArray(SQLITE3_ASSOC); - if (!$user) { http_response_code(401); echo json_encode(['error'=>'Invalid credentials']); break; } - if (!hash_equals($user['password_hash'], hash_pbkdf2('sha256', $p, $user['salt'], 100000))) { - http_response_code(401); echo json_encode(['error'=>'Invalid credentials']); break; + if (!$user) { recordAttempt($db); http_response_code(401); echo json_encode(['error'=>'Invalid credentials']); break; } + $algo = $user['hash_algo'] ?? 'pbkdf2'; + if ($algo === 'bcrypt') { + $valid = password_verify($p, $user['password_hash']); + } else { + $valid = hash_equals($user['password_hash'], hash_pbkdf2('sha256', $p, $user['salt'], 100000)); } + if (!$valid) { recordAttempt($db); logAudit($db, $user['id'], 'failed_login'); http_response_code(401); echo json_encode(['error'=>'Invalid credentials']); break; } + if ($algo !== 'bcrypt') { + $newHash = password_hash($p, PASSWORD_BCRYPT); + $upd = $db->prepare('UPDATE users SET password_hash=:h, hash_algo=:algo WHERE id=:uid'); + $upd->bindValue(':h', $newHash, SQLITE3_TEXT); + $upd->bindValue(':algo', 'bcrypt', SQLITE3_TEXT); + $upd->bindValue(':uid', $user['id'], SQLITE3_INTEGER); + $upd->execute(); + } + clearAttempts($db); $defaultFolders = ['All', 'Social', 'Banking', 'Work', 'Personal']; $stFolder = $db->prepare('INSERT OR IGNORE INTO folders (user_id, name) VALUES (:uid, :name)'); foreach ($defaultFolders as $name) { @@ -146,13 +241,16 @@ try { } $token = bin2hex(random_bytes(32)); $tokenHash = hash('sha256', $token); + $csrfToken = bin2hex(random_bytes(32)); $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 = $db->prepare('INSERT INTO sessions (user_id, token_hash, csrf_token, expires_at) VALUES (:uid, :th, :csrf, :exp)'); $stS->bindValue(':uid', $user['id'], SQLITE3_INTEGER); $stS->bindValue(':th', $tokenHash, SQLITE3_TEXT); + $stS->bindValue(':csrf', $csrfToken, SQLITE3_TEXT); $stS->bindValue(':exp', $expires, SQLITE3_TEXT); $stS->execute(); - echo json_encode(['message'=>'OK','token'=>$token,'userId'=>$user['id'],'salt'=>$user['salt']]); + logAudit($db, $user['id'], 'login'); + echo json_encode(['message'=>'OK','token'=>$token,'userId'=>$user['id'],'salt'=>$user['salt'],'csrfToken'=>$csrfToken]); break; case ($path === '/folders' && $method === 'GET'): @@ -167,6 +265,7 @@ try { case ($path === '/folders' && $method === 'POST'): $auth = authenticate($db); + requireCSRF($db, $auth['userId']); $name = trim($input['name'] ?? ''); if (!$name) { http_response_code(400); echo json_encode(['error'=>'Folder name required']); break; } if (strtolower($name) === 'all') { http_response_code(400); echo json_encode(['error'=>'Cannot use All']); break; } @@ -174,11 +273,13 @@ try { $st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER); $st->bindValue(':name', $name, SQLITE3_TEXT); try { $st->execute(); } catch (Exception $e) { http_response_code(409); echo json_encode(['error'=>'Folder exists']); break; } + logAudit($db, $auth['userId'], 'add_folder'); echo json_encode(['message'=>'Created','name'=>$name]); break; case (preg_match('/^\/folders\/(.+)$/', $path, $m) && $method === 'DELETE'): $auth = authenticate($db); + requireCSRF($db, $auth['userId']); $folderName = urldecode($m[1]); if ($folderName === 'All') { http_response_code(400); echo json_encode(['error'=>'Cannot delete All']); break; } $st = $db->prepare('DELETE FROM folders WHERE user_id=:uid AND name=:name'); @@ -190,6 +291,7 @@ try { $stUp->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER); $stUp->bindValue(':f', $folderName, SQLITE3_TEXT); $stUp->execute(); + logAudit($db, $auth['userId'], 'delete_folder'); echo json_encode(['message'=>'Deleted']); break; @@ -228,6 +330,7 @@ try { case ($path === '/entries' && $method === 'POST'): $auth = authenticate($db); + requireCSRF($db, $auth['userId']); $site = trim($input['site'] ?? ''); $username = trim($input['username'] ?? ''); $folder = trim($input['folder'] ?? 'All'); @@ -245,11 +348,13 @@ try { $st->bindValue(':f', $folder, SQLITE3_TEXT); $st->bindValue(':c', $now, SQLITE3_TEXT); $st->execute(); + logAudit($db, $auth['userId'], 'add_entry'); echo json_encode(['id'=>$db->lastInsertRowID(), 'site'=>$site, 'username'=>$username, 'folder'=>$folder]); break; case (preg_match('/^\/entries\/(\d+)$/', $path, $m) && $method === 'PUT'): $auth = authenticate($db); + requireCSRF($db, $auth['userId']); $site = trim($input['site'] ?? ''); $username = trim($input['username'] ?? ''); $encPwd = $input['encrypted_password'] ?? ''; @@ -267,12 +372,14 @@ try { $st->bindValue(':id', $m[1], SQLITE3_INTEGER); $st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER); $st->execute(); + logAudit($db, $auth['userId'], 'edit_entry'); echo json_encode(['message'=>'Updated']); break; // Soft delete case (preg_match('/^\/entries\/(\d+)$/', $path, $m) && $method === 'DELETE'): $auth = authenticate($db); + requireCSRF($db, $auth['userId']); $permanent = $_GET['permanent'] ?? '0'; if ($permanent === '1') { $st = $db->prepare('DELETE FROM vault_entries WHERE id=:id AND user_id=:uid'); @@ -282,21 +389,25 @@ try { $st->bindValue(':id', $m[1], SQLITE3_INTEGER); $st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER); $st->execute(); + logAudit($db, $auth['userId'], $permanent === '1' ? 'permanent_delete' : 'delete_entry'); echo json_encode(['message'=>'Deleted']); break; // Restore case (preg_match('/^\/entries\/(\d+)\/restore$/', $path, $m) && $method === 'POST'): $auth = authenticate($db); + requireCSRF($db, $auth['userId']); $st = $db->prepare('UPDATE vault_entries SET deleted=0, deleted_at=NULL, updated_at=datetime(\'now\') WHERE id=:id AND user_id=:uid'); $st->bindValue(':id', $m[1], SQLITE3_INTEGER); $st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER); $st->execute(); + logAudit($db, $auth['userId'], 'restore_entry'); echo json_encode(['message'=>'Restored']); break; - // Empty trash (permanently delete all soft-deleted) case ($path === '/logout' && $method === 'POST'): + $auth = authenticate($db); + requireCSRF($db, $auth['userId']); $headers = getallheaders(); $token = str_replace('Bearer ', '', $headers['Authorization'] ?? ''); if ($token) { @@ -305,24 +416,47 @@ try { $del->bindValue(':th', $tokenHash, SQLITE3_TEXT); $del->execute(); } + logAudit($db, $auth['userId'], 'logout'); echo json_encode(['message'=>'Logged out']); break; case ($path === '/entries/trash/empty' && $method === 'DELETE'): $auth = authenticate($db); + requireCSRF($db, $auth['userId']); $st = $db->prepare('DELETE FROM vault_entries WHERE user_id=:uid AND deleted=1'); $st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER); $st->execute(); + logAudit($db, $auth['userId'], 'empty_trash'); echo json_encode(['message'=>'Trash emptied']); break; + case ($path === '/reauth' && $method === 'POST'): + $auth = authenticate($db); + requireCSRF($db, $auth['userId']); + $p = $input['masterPassword'] ?? ''; + $st = $db->prepare('SELECT * FROM users WHERE id=:uid'); + $st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER); + $user = $st->execute()->fetchArray(SQLITE3_ASSOC); + if (!$user) { http_response_code(401); echo json_encode(['error'=>'User not found']); break; } + $algo = $user['hash_algo'] ?? 'pbkdf2'; + if ($algo === 'bcrypt') { + $valid = password_verify($p, $user['password_hash']); + } else { + $valid = hash_equals($user['password_hash'], hash_pbkdf2('sha256', $p, $user['salt'], 100000)); + } + if (!$valid) { logAudit($db, $auth['userId'], 'failed_reauth'); http_response_code(401); echo json_encode(['error'=>'Invalid password']); break; } + logAudit($db, $auth['userId'], 'reauth'); + echo json_encode(['message'=>'OK']); + break; + default: http_response_code(404); echo json_encode(['error'=>'Not found']); } } catch (Exception $e) { + file_put_contents($logFile, '[' . date('Y-m-d H:i:s') . '] ' . $e->getMessage() . PHP_EOL, FILE_APPEND); http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + echo json_encode(['error' => 'Internal server error']); } $db->close(); diff --git a/index.html b/index.html index 36e6df1..2756ccb 100644 --- a/index.html +++ b/index.html @@ -166,7 +166,7 @@
📭 No entries
- +
diff --git a/js/app.js b/js/app.js index d2bc78c..c63bcec 100644 --- a/js/app.js +++ b/js/app.js @@ -1,5 +1,6 @@ const API = '/password-manager/api.php'; let token = sessionStorage.getItem('authToken'); +let csrfToken = sessionStorage.getItem('csrfToken') || ''; let curUser = sessionStorage.getItem('currentUsername'); let view = localStorage.getItem('vaultView') || 'grid'; let showView = localStorage.getItem('showViewBtn') !== 'false'; @@ -157,7 +158,7 @@ async function addFolderToServer(name) { try { const r = await fetch(API + '/folders', { method: 'POST', - headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, body: JSON.stringify({ name }) }); if (r.ok) { await loadFolders(); return true; } @@ -171,7 +172,7 @@ async function deleteFolderFromServer(name) { try { const r = await fetch(API + '/folders/' + encodeURIComponent(name), { method: 'DELETE', - headers: { 'Authorization': 'Bearer ' + token } + headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); if (r.ok) { await loadFolders(); @@ -251,15 +252,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!'); await loadEntries(); playSound('success'); } } catch (e) { toast('⚠️ Error', 'error'); } + try { const r = await fetch(API + '/entries/' + id + '/restore', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); 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'); await loadEntries(); playSound('error'); } } catch (e) { toast('⚠️ Error', 'error'); } + try { const r = await fetch(API + '/entries/' + id + '?permanent=1', { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); 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'); await loadEntries(); playSound('error'); } } catch (e) { toast('⚠️ Error', 'error'); } + try { const r = await fetch(API + '/entries/trash/empty', { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); if (r.ok) { toast('🗑️ Trash emptied'); await loadEntries(); playSound('error'); } } catch (e) { toast('⚠️ Error', 'error'); } } function timeAgo(dateStr) { if (!dateStr) return ''; @@ -389,10 +390,11 @@ async function login() { const r = await fetch(API + '/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, masterPassword: p }) }); const d = await r.json(); if (r.ok) { - token = d.token; curUser = u; + token = d.token; csrfToken = d.csrfToken || ''; curUser = u; cryptoKey = await deriveKey(p, d.salt); persistCryptoKey(); sessionStorage.setItem('authToken', token); + sessionStorage.setItem('csrfToken', csrfToken); sessionStorage.setItem('currentUsername', u); await loadFolders(); toast('✅ Login!'); @@ -414,10 +416,11 @@ async function register() { const r = await fetch(API + '/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, masterPassword: p }) }); const d = await r.json(); if (r.ok) { - token = d.token; curUser = u; + token = d.token; csrfToken = d.csrfToken || ''; curUser = u; cryptoKey = await deriveKey(p, d.salt); persistCryptoKey(); sessionStorage.setItem('authToken', token); + sessionStorage.setItem('csrfToken', csrfToken); sessionStorage.setItem('currentUsername', u); await loadFolders(); toast('✅ Created!'); @@ -432,11 +435,11 @@ async function register() { async function doLogout() { saveUsername(); if (token) { - try { await fetch(API + '/logout', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token } }); } catch (e) {} + try { await fetch(API + '/logout', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); } 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; + token = null; csrfToken = ''; curUser = null; entries = []; cryptoKey = null; folders = ['All']; showTrash = false; sessionStorage.clear(); document.getElementById('authSection').classList.remove('hidden'); document.getElementById('vaultSection').classList.add('hidden'); @@ -739,7 +742,7 @@ async function saveEdit() { if (!site || !password) { toast('Site and password required', 'error'); return; } try { const enc = await encryptPwd(password); - const r = await fetch(API + '/entries/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, body: JSON.stringify({ site, username, encrypted_password: enc.encrypted, iv: enc.iv, folder }) }); + const r = await fetch(API + '/entries/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, body: JSON.stringify({ site, username, encrypted_password: enc.encrypted, iv: enc.iv, folder }) }); if (r.ok) { toast('✅ Updated!'); closeEdit(); loadEntries(); playSound('success'); } else { const d = await r.json(); toast('❌ ' + (d.error || 'Failed'), 'error'); } } catch (e) { toast('⚠️ Error', 'error'); } @@ -827,7 +830,7 @@ async function batchMove() { const enc = await encryptPwd(e.password); await fetch(API + '/entries/' + id, { method: 'PUT', - headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, body: JSON.stringify({ site: e.site, username: e.username, encrypted_password: enc.encrypted, iv: enc.iv, folder }) }); } @@ -850,7 +853,7 @@ async function addEntry() { const folder = document.getElementById('addFolder').value; const r = await fetch(API + '/entries', { method: 'POST', - headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, body: JSON.stringify({ site, username: user, encrypted_password: enc.encrypted, iv: enc.iv, encryption_method: 'client', folder }) }); if (r.ok) { @@ -867,7 +870,7 @@ async function addEntry() { } async function delEntry(id) { try { - const r = await fetch(API + '/entries/' + id, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token } }); + const r = await fetch(API + '/entries/' + id, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); if (r.ok) { order = order.filter(x => x != id); localStorage.setItem('entryOrder', JSON.stringify(order)); toast('📦 Moved to trash'); await loadEntries(); playSound('delete'); } } catch (e) { toast('Error', 'error'); } } @@ -879,7 +882,39 @@ function searchEntries() { if (btn) btn.style.display = input.value.trim() ? 'block' : 'none'; loadEntries(input.value); } -function exportPasswords() { if (!confirm('Export plain text?')) return; const b = new Blob([JSON.stringify(entries, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = 'vault-' + new Date().toISOString().slice(0, 10) + '.json'; a.click(); URL.revokeObjectURL(a.href); toast('Exported!'); } +function showExportModal() { + const overlay = document.createElement('div'); + overlay.className = 'custom-modal-overlay show'; + overlay.innerHTML = `

📤 Export Passwords

Re-enter master password to export plaintext passwords

`; + document.body.appendChild(overlay); + document.getElementById('cancelExport').onclick = () => overlay.remove(); + document.getElementById('confirmExport').onclick = async () => { + const pwd = document.getElementById('exportPassword').value; + if (!pwd) { toast('Enter your master password', 'error'); return; } + try { + const r = await fetch(API + '/reauth', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, + body: JSON.stringify({ masterPassword: pwd }) + }); + if (r.ok) { + overlay.remove(); + const b = new Blob([JSON.stringify(entries, null, 2)], { type: 'application/json' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(b); + a.download = 'vault-' + new Date().toISOString().slice(0, 10) + '.json'; + a.click(); + URL.revokeObjectURL(a.href); + toast('Exported!'); + playSound('success'); + } else { + toast('❌ Invalid password', 'error'); + } + } catch (e) { toast('⚠️ Error', 'error'); } + }; + overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); + playSound('open'); +} function esc(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; } function clearSearch() { const input = document.getElementById('searchInput');