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 @@
Enter your master password to unlock the vault