From 367b85e991666b4e7c214b14636b1d0c4aa15c61 Mon Sep 17 00:00:00 2001 From: Zaki <18zaki18@gmail.com> Date: Sat, 9 May 2026 13:01:59 +0100 Subject: [PATCH] Add WebAuthn passkey biometric unlock + dark toggle on auth screen - Dark/light theme toggle now always visible (moved outside auth/vault sections) - New passkey_challenges and passkey_credentials tables - /passkey/register/begin + /passkey/register/complete endpoints - /passkey/login/begin + /passkey/login/complete endpoints - CBOR decoder + COSE key parser for WebAuthn attestation/assertion - ES256 (P-256) signature verification via OpenSSL - Client-side: register passkey button in settings, passkey login on auth screen - First passkey login prompts for master password once to derive AES-GCM key - Passkey login requires username input before biometric prompt --- api.php | 187 +++++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 12 +++- js/app.js | 92 ++++++++++++++++++++++++++ 3 files changed, 288 insertions(+), 3 deletions(-) diff --git a/api.php b/api.php index 7bef32e..8b02b32 100644 --- a/api.php +++ b/api.php @@ -90,9 +90,28 @@ try { $db->exec("ALTER TABLE users ADD COLUMN hash_algo TEXT DEFAULT 'pbkdf2'"); 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(); @@ -145,6 +164,51 @@ function requireCSRF($db, $userId) { } } +// ==================== 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); @@ -463,6 +527,129 @@ try { 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']); diff --git a/index.html b/index.html index baa2131..f986e42 100644 --- a/index.html +++ b/index.html @@ -57,9 +57,10 @@
-

🔐 Vault XAMPP - -

+
+

🔐 Vault XAMPP

+ +
@@ -72,6 +73,7 @@ +
+
+ 🔐 Set up passkey + +
diff --git a/js/app.js b/js/app.js index 335d67d..44121e4 100644 --- a/js/app.js +++ b/js/app.js @@ -2,6 +2,8 @@ const API = '/password-manager/api.php'; let token = sessionStorage.getItem('authToken'); let csrfToken = sessionStorage.getItem('csrfToken') || ''; let curUser = sessionStorage.getItem('currentUsername'); +function a2b64(arr) { return btoa(String.fromCharCode(...new Uint8Array(arr))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,''); } +function b642ab(s) { return Uint8Array.from(atob(s.replace(/-/g,'+').replace(/_/g,'/')), c=>c.charCodeAt(0)).buffer; } let view = localStorage.getItem('vaultView') || 'grid'; let showView = localStorage.getItem('showViewBtn') !== 'false'; let showMail = localStorage.getItem('showEmail') !== 'false'; @@ -317,6 +319,34 @@ function syncSettingsUI() { document.getElementById('showEmailToggle').classList.toggle('active', showMail); document.getElementById('autoLockTimer').value = lockMin; } +async function registerPasskey() { + try { + const r = await fetch(API + '/passkey/register/begin', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } + }); + if (!r.ok) { const d = await r.json(); toast('❌ ' + (d.error || 'Failed'), 'error'); return; } + const opts = await r.json(); + opts.challenge = b642ab(opts.challenge); + opts.user.id = b642ab(opts.user.id); + if (!window.PublicKeyCredential) { toast('❌ Passkeys not supported', 'error'); return; } + const cred = await navigator.credentials.create({ publicKey: opts }); + const result = { + id: cred.id, + response: { + clientDataJSON: a2b64(cred.response.clientDataJSON), + attestationObject: a2b64(cred.response.attestationObject) + } + }; + const r2 = await fetch(API + '/passkey/register/complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, + body: JSON.stringify(result) + }); + if (r2.ok) { toast('✅ Passkey registered!'); playSound('success'); } + else { const d = await r2.json(); toast('❌ ' + (d.error || 'Failed'), 'error'); } + } catch (e) { toast('⚠️ Passkey setup failed: ' + e.message, 'error'); } +} // ==================== INIT ==================== function init() { @@ -438,6 +468,68 @@ function checkAddStrength() { // ==================== AUTH ==================== function switchTab(t) { document.querySelectorAll('.auth-tab').forEach(x => x.classList.remove('active')); event.target.classList.add('active'); document.getElementById('loginForm').classList.toggle('hidden', t !== 'login'); document.getElementById('registerForm').classList.toggle('hidden', t !== 'register'); } +async function loginWithPasskey() { + if (!window.PublicKeyCredential) { toast('❌ Passkeys not supported', 'error'); return; } + const u = document.getElementById('loginUsername').value.trim(); + if (!u) { toast('Enter username first', 'error'); return; } + document.getElementById('loginBtn').disabled = true; + try { + const r = await fetch(API + '/passkey/login/begin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: u }) + }); + if (!r.ok) { const d = await r.json(); toast('❌ ' + (d.error || 'Failed'), 'error'); document.getElementById('loginBtn').disabled = false; return; } + const opts = await r.json(); + opts.challenge = b642ab(opts.challenge); + opts.allowCredentials.forEach(c => { c.id = b642ab(c.id); }); + const cred = await navigator.credentials.get({ publicKey: opts }); + const result = { + id: cred.id, + response: { + clientDataJSON: a2b64(cred.response.clientDataJSON), + authenticatorData: a2b64(cred.response.authenticatorData), + signature: a2b64(cred.response.signature), + userHandle: cred.response.userHandle ? a2b64(cred.response.userHandle) : null + } + }; + const r2 = await fetch(API + '/passkey/login/complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(result) + }); + const d = await r2.json(); + if (r2.ok) { + token = d.token; csrfToken = d.csrfToken || ''; curUser = d.username || u; + sessionStorage.setItem('authToken', token); + sessionStorage.setItem('csrfToken', csrfToken); + sessionStorage.setItem('currentUsername', curUser); + // Try to restore crypto key from sessionStorage + const restored = await restoreCryptoKey(); + if (!restored) { + // Need master password once to derive crypto key + const mp = await new Promise(resolve => { + const overlay = document.createElement('div'); + overlay.className = 'custom-modal-overlay show'; + overlay.innerHTML = `

🔑 One more step

Enter your master password to unlock the vault

`; + document.body.appendChild(overlay); + document.getElementById('passkeyTempBtn').onclick = () => resolve(document.getElementById('passkeyTempPwd').value); + overlay.addEventListener('keydown', function handler(e) { if (e.key === 'Enter') { resolve(document.getElementById('passkeyTempPwd').value); overlay.remove(); document.removeEventListener('keydown', handler); } }); + }); + const m = document.querySelector('.custom-modal-overlay.show'); + if (m) m.remove(); + cryptoKey = await deriveKey(mp, d.salt); + persistCryptoKey(); + } + await loadFolders(); + toast('✅ Biometric login!'); + playSound('login'); + showVault(); + loadEntries(); + } else { toast('❌ ' + (d.error || 'Failed'), 'error'); } + } catch (e) { toast('⚠️ Passkey login failed: ' + e.message, 'error'); } + finally { document.getElementById('loginBtn').disabled = false; } +} async function login() { const u = document.getElementById('loginUsername').value.trim(); const p = document.getElementById('loginPassword').value;