getMessage() . PHP_EOL, FILE_APPEND); http_response_code(500); header('Content-Type: application/json'); echo json_encode(['error' => 'Internal server error']); exit; }); header('Content-Type: application/json'); header('Strict-Transport-Security: max-age=31536000; includeSubDomains'); header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'"); $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); $db_path = __DIR__ . '/vault.db'; $db = new SQLite3($db_path); $db->enableExceptions(true); $db->busyTimeout(5000); $db->exec(" CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, salt TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS folders ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, name TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, UNIQUE(user_id, name) ); CREATE TABLE IF NOT EXISTS vault_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, site TEXT NOT NULL, username TEXT NOT NULL, encrypted_password TEXT NOT NULL, iv TEXT NOT NULL, encryption_method TEXT DEFAULT 'server', folder TEXT DEFAULT 'All', deleted INTEGER DEFAULT 0, deleted_at DATETIME, 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 ); 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) {} 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) {} 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) {} try { $db->exec("ALTER TABLE vault_entries ADD COLUMN favorite INTEGER DEFAULT 0"); } catch (Exception $e) {} $db->exec(" CREATE TABLE IF NOT EXISTS passkey_challenges ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, challenge BLOB NOT NULL, type TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS passkey_credentials ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, credential_id BLOB NOT NULL UNIQUE, public_key BLOB NOT NULL, counter INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); "); $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')"); $db->exec("DELETE FROM passkey_challenges WHERE created_at < datetime('now', '-10 minutes')"); 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; } } // ==================== WebAuthn helpers ==================== function b64url_encode($d) { return rtrim(strtr(base64_encode($d), '+/', '-_'), '='); } function b64url_decode($d) { return base64_decode(strtr($d, '-_', '+/')); } function cbor_decode(&$d, &$o=0) { $b = ord($d[$o]); $m = ($b>>5)&7; $a = $b&0x1f; $o++; if ($a < 24) $v = $a; elseif ($a === 24) { $v = ord($d[$o]); $o++; } elseif ($a === 25) { $v = unpack('n', substr($d,$o,2))[1]; $o+=2; } elseif ($a === 26) { $v = unpack('N', substr($d,$o,4))[1]; $o+=4; } elseif ($a === 27) { $v = unpack('J', substr($d,$o,8))[1]; $o+=8; } else throw new Exception('CBOR: unsupported additional info'); switch ($m) { case 0: return $v; case 1: return -1-$v; case 2: $s = substr($d,$o,$v); $o+=$v; return $s; case 3: $s = substr($d,$o,$v); $o+=$v; return $s; case 4: $r=[]; for($i=0;$i<$v;$i++) $r[]=cbor_decode($d,$o); return $r; case 5: $r=[]; for($i=0;$i<$v;$i++){$k=cbor_decode($d,$o); $r[$k]=cbor_decode($d,$o);} return $r; case 7: if($a===20)return false; if($a===21)return true; if($a===22)return null; return $v; default: throw new Exception('CBOR: unsupported major type '.$m); } } function derLen($l) { if ($l < 128) return chr($l); $b = ''; while ($l > 0) { $b = chr($l&0xff).$b; $l>>=8; } return chr(0x80|strlen($b)).$b; } function coseToPem($key) { $alg = $key[3] ?? 0; if ($alg !== -7) return null; // only ES256 $x = $key[-2] ?? ''; $y = $key[-3] ?? ''; if (strlen($x) !== 32 || strlen($y) !== 32) return null; $point = "\x04".$x.$y; $algId = "\x06\x07\x2a\x86\x48\xce\x3d\x02\x01". // ecPublicKey OID "\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07"; // P-256 OID $bs = "\x03".derLen(strlen($point)+1)."\x00".$point; $inner = "\x30".derLen(strlen($algId)).$algId; $outer = $inner.$bs; $der = "\x30".derLen(strlen($outer)).$outer; return "-----BEGIN PUBLIC KEY-----\n".chunk_split(base64_encode($der), 64, "\n")."-----END PUBLIC KEY-----"; } $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $path = str_replace('/password-manager/api.php', '', $path); $path = str_replace('/api.php', '', $path); $method = $_SERVER['REQUEST_METHOD']; $input = json_decode(file_get_contents('php://input'), 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; } $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 { 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; } $st = $db->prepare('SELECT id FROM users WHERE username=:u'); $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 = 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']; $stFolder = $db->prepare('INSERT OR IGNORE INTO folders (user_id, name) VALUES (:uid, :name)'); foreach ($defaultFolders as $name) { $stFolder->bindValue(':uid', $uid, SQLITE3_INTEGER); $stFolder->bindValue(':name', $name, SQLITE3_TEXT); $stFolder->execute(); } $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, 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(); 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) { 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); $delSessions = $db->prepare('DELETE FROM sessions WHERE user_id=:uid'); $delSessions->bindValue(':uid', $user['id'], SQLITE3_INTEGER); $delSessions->execute(); $defaultFolders = ['All', 'Social', 'Banking', 'Work', 'Personal']; $stFolder = $db->prepare('INSERT OR IGNORE INTO folders (user_id, name) VALUES (:uid, :name)'); foreach ($defaultFolders as $name) { $stFolder->bindValue(':uid', $user['id'], SQLITE3_INTEGER); $stFolder->bindValue(':name', $name, SQLITE3_TEXT); $stFolder->execute(); } $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, 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(); 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'): $auth = authenticate($db); $st = $db->prepare('SELECT name FROM folders WHERE user_id=:uid ORDER BY name'); $st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER); $res = $st->execute(); $folders = []; while ($row = $res->fetchArray(SQLITE3_ASSOC)) { $folders[] = $row['name']; } echo json_encode($folders); break; 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; } $st = $db->prepare('INSERT INTO folders (user_id, name) VALUES (:uid, :name)'); $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'); $st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER); $st->bindValue(':name', $folderName, SQLITE3_TEXT); $st->execute(); if ($db->changes() === 0) { http_response_code(404); echo json_encode(['error'=>'Not found']); break; } $stUp = $db->prepare("UPDATE vault_entries SET folder='All' WHERE user_id=:uid AND folder=:f"); $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; // Entries - exclude deleted by default case ($path === '/entries' && $method === 'GET'): $auth = authenticate($db); $q = $_GET['search'] ?? ''; $showDeleted = $_GET['deleted'] ?? '0'; if ($q) { $st = $db->prepare('SELECT * FROM vault_entries WHERE user_id=:uid AND deleted=:del AND (site LIKE :q OR username LIKE :q) ORDER BY updated_at DESC'); $st->bindValue(':q', "%$q%", SQLITE3_TEXT); } else { $st = $db->prepare('SELECT * FROM vault_entries WHERE user_id=:uid AND deleted=:del ORDER BY updated_at DESC'); } $st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER); $st->bindValue(':del', $showDeleted === '1' ? 1 : 0, SQLITE3_INTEGER); $res = $st->execute(); $entries = []; while ($r = $res->fetchArray(SQLITE3_ASSOC)) { $entries[] = [ 'id' => $r['id'], 'site' => $r['site'], 'username' => $r['username'], 'encrypted_password' => $r['encrypted_password'], 'iv' => $r['iv'], 'encryption_method' => $r['encryption_method'] ?? 'server', 'folder' => $r['folder'] ?? 'All', 'deleted' => $r['deleted'], 'deleted_at' => $r['deleted_at'], 'favorite' => (int)($r['favorite'] ?? 0), 'created_at' => $r['created_at'], 'updated_at' => $r['updated_at'] ]; } echo json_encode($entries); break; 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'); $encPwd = $input['encrypted_password'] ?? ''; $iv = $input['iv'] ?? ''; if (empty($site) || empty($encPwd)) { http_response_code(400); echo json_encode(['error'=>'Site & password required']); break; } $now = date('Y-m-d H:i:s'); $st = $db->prepare('INSERT INTO vault_entries (user_id, site, username, encrypted_password, iv, encryption_method, folder, created_at, updated_at) VALUES (:uid,:s,:u,:e,:i,:m,:f,:c,:c)'); $st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER); $st->bindValue(':s', $site, SQLITE3_TEXT); $st->bindValue(':u', $username, SQLITE3_TEXT); $st->bindValue(':e', $encPwd, SQLITE3_TEXT); $st->bindValue(':i', $iv, SQLITE3_TEXT); $st->bindValue(':m', 'client', SQLITE3_TEXT); $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'] ?? ''; $iv = $input['iv'] ?? ''; $folder = trim($input['folder'] ?? 'All'); if (empty($site) || empty($encPwd)) { http_response_code(400); echo json_encode(['error'=>'Site & password required']); break; } $now = date('Y-m-d H:i:s'); $st = $db->prepare('UPDATE vault_entries SET site=:s, username=:u, encrypted_password=:e, iv=:i, folder=:f, updated_at=:c WHERE id=:id AND user_id=:uid'); $st->bindValue(':s', $site, SQLITE3_TEXT); $st->bindValue(':u', $username, SQLITE3_TEXT); $st->bindValue(':e', $encPwd, SQLITE3_TEXT); $st->bindValue(':i', $iv, SQLITE3_TEXT); $st->bindValue(':f', $folder, SQLITE3_TEXT); $st->bindValue(':c', $now, SQLITE3_TEXT); $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'); } else { $st = $db->prepare("UPDATE vault_entries SET deleted=1, deleted_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'], $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; // Favorite toggle case (preg_match('/^\/entries\/(\d+)\/favorite$/', $path, $m) && $method === 'POST'): $auth = authenticate($db); requireCSRF($db, $auth['userId']); $st = $db->prepare('UPDATE vault_entries SET favorite = CASE WHEN favorite=1 THEN 0 ELSE 1 END 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'], 'toggle_favorite'); echo json_encode(['message'=>'Toggled']); break; case ($path === '/logout' && $method === 'POST'): $auth = authenticate($db); requireCSRF($db, $auth['userId']); $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(); } 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']); if (checkRateLimit($db) >= 5) { http_response_code(429); echo json_encode(['error'=>'Too many attempts. Try again later.']); break; } $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) { recordAttempt($db); 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) { recordAttempt($db); logAudit($db, $auth['userId'], 'failed_reauth'); http_response_code(401); echo json_encode(['error'=>'Invalid password']); break; } clearAttempts($db); logAudit($db, $auth['userId'], 'reauth'); echo json_encode(['message'=>'OK']); break; // ==================== WebAuthn passkey ==================== case ($path === '/passkey/register/begin' && $method === 'POST'): $auth = authenticate($db); requireCSRF($db, $auth['userId']); $challenge = random_bytes(32); $stC = $db->prepare('INSERT INTO passkey_challenges (user_id, challenge, type) VALUES (:uid, :ch, :t)'); $stC->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER); $stC->bindValue(':ch', $challenge, SQLITE3_BLOB); $stC->bindValue(':t', 'register', SQLITE3_TEXT); $stC->execute(); $stU = $db->prepare('SELECT username FROM users WHERE id=:uid'); $stU->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER); $uRow = $stU->execute()->fetchArray(SQLITE3_ASSOC); $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; echo json_encode([ 'challenge' => b64url_encode($challenge), 'rp' => ['id' => parse_url($host, PHP_URL_HOST) ?: $host, 'name' => 'Vault'], 'user' => ['id' => b64url_encode(pack('N', $auth['userId'])), 'name' => $uRow['username'], 'displayName' => $uRow['username']], 'pubKeyCredParams' => [['type'=>'public-key','alg'=>-7],['type'=>'public-key','alg'=>-257]], 'authenticatorSelection' => ['authenticatorAttachment'=>'platform','residentKey'=>'required','userVerification'=>'required'] ]); break; case ($path === '/passkey/register/complete' && $method === 'POST'): $auth = authenticate($db); requireCSRF($db, $auth['userId']); $credId = b64url_decode($input['id'] ?? ''); $cdj = b64url_decode($input['response']['clientDataJSON'] ?? ''); $ao = b64url_decode($input['response']['attestationObject'] ?? ''); if (!$credId || !$cdj || !$ao) { http_response_code(400); echo json_encode(['error'=>'Missing data']); break; } $cd = json_decode($cdj, true); $stCh = $db->prepare("SELECT id, challenge FROM passkey_challenges WHERE user_id=:uid AND type='register' ORDER BY created_at DESC LIMIT 1"); $stCh->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER); $chRow = $stCh->execute()->fetchArray(SQLITE3_ASSOC); if (!$chRow) { http_response_code(400); echo json_encode(['error'=>'No challenge']); break; } if (!hash_equals(b64url_encode($chRow['challenge']), $cd['challenge'] ?? '')) { http_response_code(400); echo json_encode(['error'=>'Challenge mismatch']); break; } $att = cbor_decode($ao); $ad = $att['authData'] ?? ''; if (strlen($ad) < 37) { http_response_code(400); echo json_encode(['error'=>'Invalid authData']); break; } $off = 32; $flags = ord($ad[$off]); $off += 5; if (!($flags & 0x40)) { http_response_code(400); echo json_encode(['error'=>'No attested data']); break; } $off += 16; $cidLen = unpack('n', substr($ad,$off,2))[1]; $off += 2; $storedCredId = substr($ad,$off,$cidLen); $off += $cidLen; if (!hash_equals($credId, $storedCredId)) { http_response_code(400); echo json_encode(['error'=>'Credential ID mismatch']); break; } $pkCOSE = substr($ad, $off); $stIns = $db->prepare('INSERT INTO passkey_credentials (user_id, credential_id, public_key) VALUES (:uid, :cid, :pk)'); $stIns->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER); $stIns->bindValue(':cid', $credId, SQLITE3_BLOB); $stIns->bindValue(':pk', $pkCOSE, SQLITE3_BLOB); try { $stIns->execute(); } catch (Exception $e) { http_response_code(409); echo json_encode(['error'=>'Passkey already registered']); break; } $del = $db->prepare('DELETE FROM passkey_challenges WHERE id=:id'); $del->bindValue(':id', $chRow['id'], SQLITE3_INTEGER); $del->execute(); logAudit($db, $auth['userId'], 'register_passkey'); echo json_encode(['message'=>'OK']); break; case ($path === '/passkey/login/begin' && $method === 'POST'): $u = trim($input['username'] ?? ''); if (!$u) { http_response_code(400); echo json_encode(['error'=>'Username required']); break; } $stU = $db->prepare('SELECT id FROM users WHERE username=:u'); $stU->bindValue(':u', $u, SQLITE3_TEXT); $user = $stU->execute()->fetchArray(SQLITE3_ASSOC); if (!$user) { http_response_code(404); echo json_encode(['error'=>'User not found']); break; } $stCr = $db->prepare('SELECT credential_id FROM passkey_credentials WHERE user_id=:uid'); $stCr->bindValue(':uid', $user['id'], SQLITE3_INTEGER); $res = $stCr->execute(); $allow = []; while ($row = $res->fetchArray(SQLITE3_ASSOC)) $allow[] = ['type'=>'public-key','id'=>b64url_encode($row['credential_id'])]; if (!$allow) { http_response_code(404); echo json_encode(['error'=>'No passkey registered']); break; } $challenge = random_bytes(32); $stCh = $db->prepare('INSERT INTO passkey_challenges (user_id, challenge, type) VALUES (:uid, :ch, :t)'); $stCh->bindValue(':uid', $user['id'], SQLITE3_INTEGER); $stCh->bindValue(':ch', $challenge, SQLITE3_BLOB); $stCh->bindValue(':t', 'login', SQLITE3_TEXT); $stCh->execute(); echo json_encode(['challenge'=>b64url_encode($challenge),'allowCredentials'=>$allow,'userVerification'=>'required']); break; case ($path === '/passkey/login/complete' && $method === 'POST'): $credId = b64url_decode($input['id'] ?? ''); $cdj = b64url_decode($input['response']['clientDataJSON'] ?? ''); $ad = b64url_decode($input['response']['authenticatorData'] ?? ''); $sig = b64url_decode($input['response']['signature'] ?? ''); if (!$credId || !$cdj || !$ad || !$sig) { http_response_code(400); echo json_encode(['error'=>'Missing assertion data']); break; } $stCr = $db->prepare('SELECT id, user_id, public_key FROM passkey_credentials WHERE credential_id=:cid'); $stCr->bindValue(':cid', $credId, SQLITE3_BLOB); $cred = $stCr->execute()->fetchArray(SQLITE3_ASSOC); if (!$cred) { http_response_code(401); echo json_encode(['error'=>'Credential not found']); break; } $stCh = $db->prepare("SELECT id, challenge FROM passkey_challenges WHERE user_id=:uid AND type='login' ORDER BY created_at DESC LIMIT 1"); $stCh->bindValue(':uid', $cred['user_id'], SQLITE3_INTEGER); $chRow = $stCh->execute()->fetchArray(SQLITE3_ASSOC); if (!$chRow) { http_response_code(400); echo json_encode(['error'=>'No challenge']); break; } $cd = json_decode($cdj, true); if (!hash_equals(b64url_encode($chRow['challenge']), $cd['challenge'] ?? '')) { http_response_code(400); echo json_encode(['error'=>'Challenge mismatch']); break; } $coseKey = cbor_decode($cred['public_key']); $pem = coseToPem($coseKey); if (!$pem) { http_response_code(500); echo json_encode(['error'=>'Unsupported key type']); break; } $pk = openssl_get_publickey($pem); if (!$pk) { http_response_code(500); echo json_encode(['error'=>'Failed to parse public key']); break; } $signedData = $ad . hash('sha256', $cdj, true); $ok = openssl_verify($signedData, $sig, $pk, OPENSSL_ALGO_SHA256); if (!$ok) { http_response_code(401); echo json_encode(['error'=>'Invalid signature']); break; } $del = $db->prepare('DELETE FROM passkey_challenges WHERE id=:id'); $del->bindValue(':id', $chRow['id'], SQLITE3_INTEGER); $del->execute(); $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, csrf_token, expires_at) VALUES (:uid, :th, :csrf, :exp)'); $stS->bindValue(':uid', $cred['user_id'], SQLITE3_INTEGER); $stS->bindValue(':th', $tokenHash, SQLITE3_TEXT); $stS->bindValue(':csrf', $csrfToken, SQLITE3_TEXT); $stS->bindValue(':exp', $expires, SQLITE3_TEXT); $stS->execute(); $stU = $db->prepare('SELECT username, salt FROM users WHERE id=:uid'); $stU->bindValue(':uid', $cred['user_id'], SQLITE3_INTEGER); $user = $stU->execute()->fetchArray(SQLITE3_ASSOC); logAudit($db, $cred['user_id'], 'passkey_login'); echo json_encode(['message'=>'OK','token'=>$token,'csrfToken'=>$csrfToken,'userId'=>$cred['user_id'],'username'=>$user['username'],'salt'=>$user['salt']]); 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' => 'Internal server error']); } $db->close(); ?>