'Log in first.'], 403); } } // The browser sends JSON, so the CSRF token rides in a header rather than a // form field. Same-origin is enforced by the token, not by the header alone. function check_json_csrf(): void { $sent = (string)($_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''); if (!hash_equals(csrf_token(), $sent)) { say(['error' => 'Bad or missing CSRF token.'], 400); } } if ($_SERVER['REQUEST_METHOD'] !== 'POST') { say(['error' => 'POST only.'], 405); } check_json_csrf(); if (!webauthn_available()) { say(['error' => 'Passkeys need HTTPS (or localhost). This page is not on a secure origin.'], 400); } $body = json_decode((string)file_get_contents('php://input'), true) ?: []; $action = (string)($_GET['action'] ?? ''); switch ($action) { // ---- Registration: only someone already logged in may add a passkey. case 'register-options': require_login_json(); $challenge = random_bytes(32); $_SESSION['wa_challenge'] = $challenge; // Existing credentials are excluded so the authenticator refuses to // enrol the same device twice instead of silently making a duplicate. $exclude = []; $st = db()->prepare('SELECT credential_id FROM passkeys WHERE rp_id = ?'); $st->execute([webauthn_rp_id()]); foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $cid) { $exclude[] = ['type' => 'public-key', 'id' => $cid]; } say([ 'challenge' => b64url_encode($challenge), 'rp' => ['id' => webauthn_rp_id(), 'name' => config()['site_name']], // A WebAuthn user handle is required but this app has exactly one // account, so it is a fixed opaque value rather than a real id. 'user' => [ 'id' => b64url_encode('autofeed-owner'), 'name' => 'owner', 'displayName' => config()['site_name'] . ' owner', ], 'pubKeyCredParams' => [ ['type' => 'public-key', 'alg' => -7], // ES256 ['type' => 'public-key', 'alg' => -257], // RS256 ], 'authenticatorSelection' => [ 'residentKey' => 'preferred', // discoverable: lets login work without typing anything 'userVerification' => 'preferred', ], 'excludeCredentials' => $exclude, 'timeout' => 60000, 'attestation' => 'none', ]); // no break — say() exits case 'register-verify': require_login_json(); $challenge = (string)($_SESSION['wa_challenge'] ?? ''); unset($_SESSION['wa_challenge']); // one challenge, one use if ($challenge === '') { say(['error' => 'That registration expired. Try again.'], 400); } try { $cred = webauthn_verify_registration( b64url_decode((string)($body['clientDataJSON'] ?? '')), b64url_decode((string)($body['attestationObject'] ?? '')), $challenge ); } catch (Throwable $e) { say(['error' => 'Could not register that passkey: ' . $e->getMessage()], 400); } $label = trim((string)($body['label'] ?? '')); $label = $label === '' ? 'Passkey' : mb_substr($label, 0, 60); backup_db(); db()->prepare( 'INSERT OR REPLACE INTO passkeys (credential_id, public_key, sign_count, rp_id, label) VALUES (?,?,?,?,?)' )->execute([$cred['credential_id'], $cred['public_key'], $cred['sign_count'], webauthn_rp_id(), $label]); say(['ok' => true]); // ---- Login: reachable logged out, by definition. case 'login-options': $challenge = random_bytes(32); $_SESSION['wa_challenge'] = $challenge; $allow = []; $st = db()->prepare('SELECT credential_id FROM passkeys WHERE rp_id = ?'); $st->execute([webauthn_rp_id()]); foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $cid) { $allow[] = ['type' => 'public-key', 'id' => $cid]; } say([ 'challenge' => b64url_encode($challenge), 'rpId' => webauthn_rp_id(), 'allowCredentials' => $allow, 'userVerification' => 'preferred', 'timeout' => 60000, ]); case 'login-verify': $challenge = (string)($_SESSION['wa_challenge'] ?? ''); unset($_SESSION['wa_challenge']); if ($challenge === '') { say(['error' => 'That sign-in expired. Try again.'], 400); } $credId = (string)($body['id'] ?? ''); $st = db()->prepare('SELECT * FROM passkeys WHERE credential_id = ? AND rp_id = ?'); $st->execute([$credId, webauthn_rp_id()]); $row = $st->fetch(); if (!$row) { say(['error' => 'That passkey is not registered here.'], 400); } try { $count = webauthn_verify_assertion( b64url_decode((string)($body['clientDataJSON'] ?? '')), b64url_decode((string)($body['authenticatorData'] ?? '')), b64url_decode((string)($body['signature'] ?? '')), $challenge, (string)$row['public_key'], (int)$row['sign_count'] ); } catch (Throwable $e) { say(['error' => 'Sign-in failed: ' . $e->getMessage()], 400); } db()->prepare("UPDATE passkeys SET sign_count = ?, last_used_at = datetime('now') WHERE id = ?") ->execute([$count, $row['id']]); log_in(); remember_login(); say(['ok' => true, 'redirect' => 'index.php']); } say(['error' => 'Unknown action.'], 400);