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:
2026-05-09 13:01:59 +01:00
parent 8dd2fc71cf
commit 367b85e991
3 changed files with 288 additions and 3 deletions
+187
View File
@@ -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']);