From 1054c2c7da147882347bff851e0f3c6cd3902c21 Mon Sep 17 00:00:00 2001 From: clavisound Date: Wed, 11 Mar 2026 03:06:55 +0200 Subject: [PATCH] gemini example. --- example-gemini/README.md | 21 ++++ example-gemini/example.php | 214 +++++++++++++++++++++++++++++++++++++ example-gemini/server.php | 187 ++++++++++++++++++++++++++++++++ 3 files changed, 422 insertions(+) create mode 100644 example-gemini/README.md create mode 100644 example-gemini/example.php create mode 100644 example-gemini/server.php diff --git a/example-gemini/README.md b/example-gemini/README.md new file mode 100644 index 0000000..d032056 --- /dev/null +++ b/example-gemini/README.md @@ -0,0 +1,21 @@ +# WebAuthn Passkeys Example with SQLite + +Simple example implementation of Passkeys using the `lbuchs/WebAuthn` library, using a SQLite database. + +## Features Showcased +- **Registration**: Creating a new passkey bound to a username. +- **Username-bound Login**: Logging in by typing the username and prompting for the specific passkey. +- **Usernameless Login (Discoverable Credentials)**: Logging in without typing a username by letting the device identify the user automatically. +- **Seamless Database Integration**: Uses a standard `users` and `passkeys` table schema to manage multiple keys per user. + +## Modern Security Enhancements Included +This example incorporates crucial security and reliability fixes for modern browsers: +- **CSRF Protection**: Enforces `Content-Type: application/json` on all POST endpoints, protecting against cross-origin malicious form submissions. +- **Robust Base64URL Handling**: Client-side JavaScript gracefully normalizes Base64URL encoding (converting `-`/`_` and re-applying `=` padding) to prevent `window.atob()` crash exceptions on iOS Safari. +- **Safe JSON Parsing & Type Safety**: Exceptions are rigorously caught and returned as JSON, preventing `JSON.parse` failures on the client side when PHP encounters silent warnings or `TypeErrors` (e.g., when authenticators omit optional fields like `userHandle`). +- **Enumeration Protection**: Generating the login challenge does not reveal whether a given username actually exists in the database. +- **Session Deserialization Safety**: The WebAuthn library (`WebAuthn.php`) is loaded *before* calling `session_start()`. Because the library stores complex objects (like `ByteBuffer`) in the PHP `$_SESSION` array, PHP must know about the class definition before deserializing the session data, otherwise it will result in a fatal `__PHP_Incomplete_Class` serialization error. +- **iOS 15.8.6 Safari Workaround**: Preloads the Usernameless login challenge asynchronously when the page loads. This bypasses Safari's extremely strict user-gesture timeouts which would otherwise silently cancel the Face ID/Touch ID prompt with a "This request has been canceled by the user" error if the server response takes too long. + +Server tested with PHP-8.2.30 and sqlite3 enabled in PHP. +Client tested: Firefox and KeePassXC, iOS-15.8.6 \ No newline at end of file diff --git a/example-gemini/example.php b/example-gemini/example.php new file mode 100644 index 0000000..54b8f8e --- /dev/null +++ b/example-gemini/example.php @@ -0,0 +1,214 @@ + + + + + + WebAuthn Passkey Example + + + +

Passkey Example (SQLite)

+

This example demonstrates how to implement secure, modern Passkeys using the lbuchs/WebAuthn library.

+ +
+ +
+

1. Register

+ + +
+ +
+

2. Login

+ + Leave blank for Usernameless Login (Discoverable Credentials). + +
+ + + + \ No newline at end of file diff --git a/example-gemini/server.php b/example-gemini/server.php new file mode 100644 index 0000000..d0ec682 --- /dev/null +++ b/example-gemini/server.php @@ -0,0 +1,187 @@ +enableExceptions(true); + + $db->exec("CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL + )"); + + $db->exec("CREATE TABLE IF NOT EXISTS passkeys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + credential_id TEXT UNIQUE NOT NULL, + public_key TEXT NOT NULL, + user_handle TEXT NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) + )"); + +} catch (Exception $e) { + echo json_encode(['success' => false, 'msg' => 'Database connection failed: ' . $e->getMessage()]); + exit; +} + +$fn = $_GET['fn'] ?? ''; +$rpId = $_SERVER['HTTP_HOST'] ?? 'localhost'; +if (strpos($rpId, ':') !== false) { + $rpId = explode(':', $rpId)[0]; // Remove port if present +} + +try { + $WebAuthn = new \lbuchs\WebAuthn\WebAuthn('Passkey Example App', $rpId, ['android-key', 'android-safetynet', 'apple', 'fido-u2f', 'none', 'packed', 'tpm']); + + if ($fn === 'getCreateArgs') { + $username = filter_input(INPUT_GET, 'username', FILTER_SANITIZE_SPECIAL_CHARS); + if (!$username) { + throw new Exception('Username required for registration.'); + } + + // Get or create user + $stmt = $db->prepare("SELECT id FROM users WHERE username = ?"); + $stmt->bindValue(1, $username); + $result = $stmt->execute(); + $user = $result->fetchArray(SQLITE3_ASSOC); + + if (!$user) { + $stmt = $db->prepare("INSERT INTO users (username) VALUES (?)"); + $stmt->bindValue(1, $username); + $stmt->execute(); + $userId = $db->lastInsertRowID(); + } else { + $userId = $user['id']; + } + + // Store active registration attempt in session + $_SESSION['register_user_id'] = $userId; + $_SESSION['register_username'] = $username; + + // Generate binary user handle + $userHandle = hash('sha256', (string)$userId, true); + + // $requireResidentKey = true ensures "Usernameless Login" works + $createArgs = $WebAuthn->getCreateArgs($userHandle, $username, $username, 60*4, true, 'preferred'); + + $_SESSION['webauthn_challenge'] = $WebAuthn->getChallenge(); + echo json_encode($createArgs); + + } elseif ($fn === 'processCreate') { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + throw new Exception('POST required.'); + } + if (strpos($_SERVER['CONTENT_TYPE'] ?? '', 'application/json') === false) { + throw new Exception('Invalid Content-Type. Must be application/json.'); + } + + $post = json_decode(file_get_contents('php://input')); + $clientDataJSON = base64_decode($post->clientDataJSON ?? ''); + $attestationObject = base64_decode($post->attestationObject ?? ''); + $challenge = $_SESSION['webauthn_challenge'] ?? ''; + + $data = $WebAuthn->processCreate($clientDataJSON, $attestationObject, $challenge, false, true, false); + + if (!isset($_SESSION['register_user_id'])) { + throw new Exception('Registration session expired.'); + } + + $userId = $_SESSION['register_user_id']; + $credentialId = base64_encode($data->credentialId); + $publicKey = $data->credentialPublicKey; + $userHandle = base64_encode(hash('sha256', (string)$userId, true)); + + $stmt = $db->prepare("INSERT INTO passkeys (user_id, credential_id, public_key, user_handle) VALUES (?, ?, ?, ?)"); + $stmt->bindValue(1, $userId); + $stmt->bindValue(2, $credentialId); + $stmt->bindValue(3, $publicKey); + $stmt->bindValue(4, $userHandle); + $stmt->execute(); + + // Clean up session + unset($_SESSION['register_user_id']); + unset($_SESSION['register_username']); + + echo json_encode(['success' => true, 'msg' => 'Registration successful! You can now log in.']); + + } elseif ($fn === 'getGetArgs') { + $username = filter_input(INPUT_GET, 'username', FILTER_SANITIZE_SPECIAL_CHARS); + $ids = []; + + // If username is provided, filter allowed passkeys to that specific user. + // If empty, the array remains empty, prompting a Discoverable Credential (usernameless) login. + if ($username) { + $stmt = $db->prepare("SELECT p.credential_id FROM passkeys p JOIN users u ON p.user_id = u.id WHERE u.username = ?"); + $stmt->bindValue(1, $username); + $result = $stmt->execute(); + while ($row = $result->fetchArray(SQLITE3_ASSOC)) { + $ids[] = base64_decode($row['credential_id']); + } + } + + $getArgs = $WebAuthn->getGetArgs($ids, 60*4, true, true, true, true, true, 'preferred'); + $_SESSION['webauthn_challenge'] = $WebAuthn->getChallenge(); + + echo json_encode($getArgs); + + } elseif ($fn === 'processGet') { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + throw new Exception('POST required.'); + } + if (strpos($_SERVER['CONTENT_TYPE'] ?? '', 'application/json') === false) { + throw new Exception('Invalid Content-Type. Must be application/json.'); + } + + $post = json_decode(file_get_contents('php://input')); + $clientDataJSON = base64_decode($post->clientDataJSON ?? ''); + $authenticatorData = base64_decode($post->authenticatorData ?? ''); + $signature = base64_decode($post->signature ?? ''); + $userHandle = base64_decode($post->userHandle ?? ''); + $id = base64_decode($post->id ?? ''); + + $challenge = $_SESSION['webauthn_challenge'] ?? ''; + $credentialId = base64_encode($id); + + $stmt = $db->prepare("SELECT u.username, p.public_key FROM passkeys p JOIN users u ON p.user_id = u.id WHERE p.credential_id = ?"); + $stmt->bindValue(1, $credentialId); + $result = $stmt->execute(); + $row = $result->fetchArray(SQLITE3_ASSOC); + + if ($row) { + $WebAuthn->processGet($clientDataJSON, $authenticatorData, $signature, $row['public_key'], $challenge, null, false); + + // Login success - in a real app, initialize user session here. + $_SESSION['logged_in_user'] = $row['username']; + + echo json_encode(['success' => true, 'msg' => 'Successfully logged in as ' . $row['username'] . '!']); + } else { + throw new Exception('Passkey not found in the database.'); + } + + } else { + throw new Exception('Unknown function.'); + } + +} catch (Throwable $e) { + // Catching Throwable ensures Fatal Errors (like TypeErrors) are also returned as valid JSON + echo json_encode(['success' => false, 'msg' => $e->getMessage()]); +}