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
This commit is contained in:
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user