Last active
August 23, 2025 18:00
-
-
Save nst/f50b4774827c27c4de749742ef0ea046 to your computer and use it in GitHub Desktop.
PassKeys demo
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
session_start(); | |
$users_file = 'users.json'; | |
$pending_file = 'pending.json'; | |
function loadData($file) { | |
if (!file_exists($file)) { | |
return []; | |
} | |
$content = file_get_contents($file); | |
$data = json_decode($content, true); | |
if (!is_array($data)) { | |
return []; | |
} | |
return $data; | |
} | |
function loadUsers($file) { | |
$data = loadData($file); | |
// Clean up and restore binary data in public keys for users file only | |
$cleanData = []; | |
foreach ($data as $username => $userData) { | |
if (is_array($userData) && isset($userData['publicKey'])) { | |
$cleanData[$username] = $userData; | |
$cleanData[$username]['publicKey'] = restorePublicKey($userData['publicKey']); | |
} | |
} | |
return $cleanData; | |
} | |
function saveUsers($file, $users) { | |
// Prepare all users' public keys for storage | |
$preparedUsers = []; | |
foreach ($users as $username => $userData) { | |
if (is_array($userData)) { | |
$preparedUsers[$username] = $userData; | |
if (isset($userData['publicKey'])) { | |
$preparedUsers[$username]['publicKey'] = preparePublicKey($userData['publicKey']); | |
} | |
} | |
} | |
return saveData($file, $preparedUsers); | |
} | |
function saveData($file, $data) { | |
$json = json_encode($data, JSON_PRETTY_PRINT); | |
return $json !== false && file_put_contents($file, $json) !== false; | |
} | |
function preparePublicKey($publicKey) { | |
if (!is_array($publicKey)) return $publicKey; | |
$prepared = []; | |
foreach ($publicKey as $key => $value) { | |
if (is_string($value) && !mb_check_encoding($value, 'UTF-8')) { | |
$prepared[$key] = ['_binary' => true, 'data' => base64_encode($value)]; | |
} else { | |
$prepared[$key] = $value; | |
} | |
} | |
return $prepared; | |
} | |
function restorePublicKey($publicKey) { | |
if (!is_array($publicKey)) return $publicKey; | |
$restored = []; | |
foreach ($publicKey as $key => $value) { | |
if (is_array($value) && isset($value['_binary']) && $value['_binary'] === true) { | |
$decoded = base64_decode($value['data']); | |
if ($decoded === false) { | |
error_log("Failed to decode base64 data for key $key"); | |
$restored[$key] = $value; // Keep original if decode fails | |
} else { | |
$restored[$key] = $decoded; | |
} | |
} else { | |
$restored[$key] = $value; | |
} | |
} | |
return $restored; | |
} | |
function b64encode($data) { | |
return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); | |
} | |
function b64decode($data) { | |
return base64_decode(strtr($data, '-_', '+/') . str_repeat('=', 3 - (3 + strlen($data)) % 4)); | |
} | |
function randomBytes($length) { | |
try { | |
return random_bytes($length); | |
} catch (Exception $e) { | |
// Fallback for systems where random_bytes might fail (e.g., old PHP versions or missing /dev/urandom) | |
// This is not cryptographically secure and should only be used as a last resort in a demo. | |
// For production, ensure random_bytes is available. | |
error_log("random_bytes failed: " . $e->getMessage() . ". Falling back to insecure random number generation."); | |
$bytes = ''; | |
for ($i = 0; $i < $length; $i++) { | |
$bytes .= chr(mt_rand(0, 255)); | |
} | |
return $bytes; | |
} | |
} | |
function decodeCBOR($data) { | |
$pos = 0; | |
return decodeCBORValue($data, $pos); | |
} | |
function decodeCBORValue($data, &$pos) { | |
if ($pos >= strlen($data)) return null; | |
$byte = ord($data[$pos++]); | |
$majorType = ($byte >> 5) & 0x07; | |
$additionalInfo = $byte & 0x1f; | |
switch ($majorType) { | |
case 0: return decodeCBORUint($data, $pos, $additionalInfo); | |
case 1: return -1 - decodeCBORUint($data, $pos, $additionalInfo); | |
case 2: | |
case 3: | |
$length = decodeCBORUint($data, $pos, $additionalInfo); | |
$result = substr($data, $pos, $length); | |
$pos += $length; | |
return $result; | |
case 4: | |
$length = decodeCBORUint($data, $pos, $additionalInfo); | |
$result = []; | |
for ($i = 0; $i < $length; $i++) { | |
$result[] = decodeCBORValue($data, $pos); | |
} | |
return $result; | |
case 5: | |
$length = decodeCBORUint($data, $pos, $additionalInfo); | |
$result = []; | |
for ($i = 0; $i < $length; $i++) { | |
$key = decodeCBORValue($data, $pos); | |
$value = decodeCBORValue($data, $pos); | |
$result[$key] = $value; | |
} | |
return $result; | |
case 7: | |
if ($additionalInfo == 20) return false; | |
if ($additionalInfo == 21) return true; | |
if ($additionalInfo == 22) return null; | |
} | |
return null; | |
} | |
function decodeCBORUint($data, &$pos, $additionalInfo) { | |
if ($additionalInfo < 24) return $additionalInfo; | |
if ($additionalInfo == 24) return ord($data[$pos++]); | |
if ($additionalInfo == 25) { | |
$result = (ord($data[$pos]) << 8) | ord($data[$pos + 1]); | |
$pos += 2; | |
return $result; | |
} | |
if ($additionalInfo == 26) { | |
$result = (ord($data[$pos]) << 24) | (ord($data[$pos + 1]) << 16) | | |
(ord($data[$pos + 2]) << 8) | ord($data[$pos + 3]); | |
$pos += 4; | |
return $result; | |
} | |
// Handle 64-bit integers (for very large lengths) | |
if ($additionalInfo == 27) { | |
$high = (ord($data[$pos]) << 24) | (ord($data[$pos + 1]) << 16) | | |
(ord($data[$pos + 2]) << 8) | ord($data[$pos + 3]); | |
$low = (ord($data[$pos + 4]) << 24) | (ord($data[$pos + 5]) << 16) | | |
(ord($data[$pos + 6]) << 8) | ord($data[$pos + 7]); | |
$pos += 8; | |
// This won't correctly represent full 64-bit integers on 32-bit PHP, | |
// but for typical WebAuthn data, 32-bit is usually sufficient for lengths. | |
return ($high << 32) | $low; | |
} | |
return 0; | |
} | |
function extractPublicKey($attestationObjectB64) { | |
$attestationObject = decodeCBOR(b64decode($attestationObjectB64)); | |
if (!isset($attestationObject['authData']) || strlen($attestationObject['authData']) < 37) { | |
return ['error' => 'Invalid attestation data']; | |
} | |
$authData = $attestationObject['authData']; | |
$flags = ord($authData[32]); | |
if (($flags & 0x40) === 0) { // Check AT flag for attested credential data | |
return ['error' => 'No credential data (AT flag not set)']; | |
} | |
$offset = 37; // Skip RP ID Hash (32 bytes) + Flags (1 byte) + Sign Count (4 bytes) | |
// AAGUID (16 bytes) | |
$aaguid = substr($authData, $offset, 16); | |
$offset += 16; | |
// Credential ID Length (2 bytes) | |
$credIdLength = unpack('n', substr($authData, $offset, 2))[1]; | |
$offset += 2; | |
// Credential ID | |
$credentialId = substr($authData, $offset, $credIdLength); | |
$offset += $credIdLength; | |
// COSE Public Key | |
$publicKey = decodeCBOR(substr($authData, $offset)); | |
if (!$publicKey) { | |
return ['error' => 'Failed to decode public key']; | |
} | |
return [ | |
'publicKey' => $publicKey, | |
'credentialId' => b64encode($credentialId), | |
'aaguid' => bin2hex($aaguid), | |
'signCount' => unpack('N', substr($authData, 33, 4))[1] // Sign count is at byte 33 | |
]; | |
} | |
function formatPublicKey($publicKey) { | |
if (!is_array($publicKey)) return 'Invalid public key format'; | |
$output = "COSE Public Key:\n"; | |
// Key type and algorithm | |
$keyTypes = [1 => 'OKP', 2 => 'EC2', 3 => 'RSA']; | |
$algorithms = [-7 => 'ES256', -257 => 'RS256', -8 => 'EdDSA']; // -8 is EdDSA for OKP | |
if (isset($publicKey[1])) { // kty | |
$kty = $publicKey[1]; | |
$output .= "• Key Type: $kty (" . ($keyTypes[$kty] ?? 'Unknown') . ")\n"; | |
} | |
if (isset($publicKey[3])) { // alg | |
$alg = $publicKey[3]; | |
$output .= "• Algorithm: $alg (" . ($algorithms[$alg] ?? 'Unknown') . ")\n"; | |
} | |
// EC2 key details (kty = 2) | |
if (isset($publicKey[1]) && $publicKey[1] == 2) { | |
$curves = [1 => 'P-256', 2 => 'P-384', 3 => 'P-521']; | |
if (isset($publicKey[-1])) { // crv | |
$crv = $publicKey[-1]; | |
$output .= "• Curve: $crv (" . ($curves[$crv] ?? 'Unknown') . ")\n"; | |
} | |
if (isset($publicKey[-2])) { // x-coordinate | |
$output .= "• X coordinate: " . bin2hex($publicKey[-2]) . "\n"; | |
} | |
if (isset($publicKey[-3])) { // y-coordinate | |
$output .= "• Y coordinate: " . bin2hex($publicKey[-3]) . "\n"; | |
} | |
} | |
// OKP key details (kty = 1) | |
if (isset($publicKey[1]) && $publicKey[1] == 1) { | |
$curves = [6 => 'Ed25519', 7 => 'X25519']; // Example curves for OKP | |
if (isset($publicKey[-1])) { // crv | |
$crv = $publicKey[-1]; | |
$output .= "• Curve: $crv (" . ($curves[$crv] ?? 'Unknown') . ")\n"; | |
} | |
if (isset($publicKey[-2])) { // x-coordinate (public key for OKP) | |
$output .= "• Public Key (x): " . bin2hex($publicKey[-2]) . "\n"; | |
} | |
} | |
// RSA key details (kty = 3) | |
if (isset($publicKey[1]) && $publicKey[1] == 3) { | |
if (isset($publicKey[-1])) { // n (modulus) | |
$output .= "• Modulus (n): " . bin2hex($publicKey[-1]) . "\n"; | |
} | |
if (isset($publicKey[-2])) { // e (exponent) | |
$output .= "• Exponent (e): " . bin2hex($publicKey[-2]) . "\n"; | |
} | |
} | |
return $output; | |
} | |
function verifySignature($publicKey, $signedData, $signature) { | |
if (!is_array($publicKey) || !isset($publicKey[1]) || $publicKey[1] != 2) { // Only ES256 (EC2) supported here | |
error_log("verifySignature: Invalid public key format or type. Only EC2 (type 2) is supported for signature verification."); | |
return false; | |
} | |
if (!isset($publicKey[-2]) || !isset($publicKey[-3])) { | |
error_log("verifySignature: Missing X or Y coordinates in public key."); | |
return false; | |
} | |
$x = $publicKey[-2]; | |
$y = $publicKey[-3]; | |
// Convert public key to PEM format | |
$publicKeyPem = createPemFromCoordinates($x, $y); | |
if ($publicKeyPem === false) { | |
error_log("verifySignature: Failed to create PEM from coordinates."); | |
return false; | |
} | |
// WebAuthn signatures are DER-encoded. OpenSSL expects a DER-encoded signature for EC. | |
// The signature provided by WebAuthn is already DER-encoded. | |
$result = openssl_verify($signedData, $signature, $publicKeyPem, OPENSSL_ALGO_SHA256); | |
if ($result === -1) { | |
error_log("OpenSSL error during signature verification: " . openssl_error_string()); | |
} | |
return $result === 1; | |
} | |
function createPemFromCoordinates($x, $y) { | |
// P-256 (prime256v1) OID: 1.2.840.10045.3.1.7 | |
$oid_ecPublicKey = hex2bin('2A8648CE3D0201'); // 1.2.840.10045.2.1 (ecPublicKey) | |
$oid_prime256v1 = hex2bin('2A8648CE3D030107'); // 1.2.840.10045.3.1.7 (prime256v1) | |
// SEQUENCE { SEQUENCE { ecPublicKey, prime256v1 }, BIT STRING } | |
// BIT STRING is 0x04 || X || Y for uncompressed point | |
// SubjectPublicKeyInfo ::= SEQUENCE { | |
// algorithm AlgorithmIdentifier, | |
// subjectPublicKey BIT STRING } | |
// AlgorithmIdentifier ::= SEQUENCE { | |
// algorithm OBJECT IDENTIFIER, | |
// parameters ANY DEFINED BY algorithm OPTIONAL } | |
// ECPoint is 04 || X || Y | |
$ecPoint = "\x04" . $x . $y; | |
// Build the ASN.1 structure | |
// AlgorithmIdentifier (SEQUENCE of OIDs) | |
$algo_sequence = "\x30\x13" . // SEQUENCE (19 bytes) | |
"\x06\x07" . $oid_ecPublicKey . // OBJECT IDENTIFIER ecPublicKey (7 bytes) | |
"\x06\x08" . $oid_prime256v1; // OBJECT IDENTIFIER prime256v1 (8 bytes) | |
// BIT STRING (public key) | |
// The 0x00 at the beginning indicates 0 unused bits in the last byte | |
$bit_string = "\x03" . chr(strlen($ecPoint) + 1) . "\x00" . $ecPoint; | |
// Full SubjectPublicKeyInfo SEQUENCE | |
$publicKeyDer = "\x30" . chr(strlen($algo_sequence) + strlen($bit_string)) . | |
$algo_sequence . $bit_string; | |
return "-----BEGIN PUBLIC KEY-----\n" . | |
chunk_split(base64_encode($publicKeyDer), 64, "\n") . | |
"-----END PUBLIC KEY-----\n"; | |
} | |
$users = loadUsers($users_file); | |
$pending = loadData($pending_file); | |
if (!is_array($users)) $users = []; | |
if (!is_array($pending)) $pending = []; | |
$action = $_GET['action'] ?? 'home'; | |
$msg = $_SESSION['msg'] ?? ''; | |
unset($_SESSION['msg']); | |
// Handle AJAX requests for registration/login options | |
if ($action === 'get_register_options' && $_SERVER['REQUEST_METHOD'] === 'POST') { | |
header('Content-Type: application/json'); | |
$username = $_POST['username'] ?? ''; | |
if (isset($users[$username])) { | |
echo json_encode(['error' => "Username '$username' already exists"]); | |
exit; | |
} | |
$challenge = randomBytes(32); | |
$token = bin2hex(randomBytes(16)); | |
$pending[$token] = [ | |
'type' => 'register', | |
'username' => $username, | |
'challenge' => b64encode($challenge), | |
'expires' => time() + 300 // 5 minutes | |
]; | |
saveData($pending_file, $pending); | |
$options = [ | |
'challenge' => b64encode($challenge), | |
'rp' => ['name' => 'Passkeys Demo'], | |
'user' => [ | |
'id' => b64encode($username), | |
'name' => $username, | |
'displayName' => $username | |
], | |
'pubKeyCredParams' => [['type' => 'public-key', 'alg' => -7]], | |
'timeout' => 60000, | |
'authenticatorSelection' => ['userVerification' => 'preferred'], // <--- REMOVE 'authenticatorAttachment' entirely | |
'attestation' => 'none' | |
]; | |
echo json_encode(['options' => $options, 'token' => $token]); | |
exit; | |
} | |
if ($action === 'get_login_options' && $_SERVER['REQUEST_METHOD'] === 'POST') { | |
header('Content-Type: application/json'); | |
$username = $_POST['username'] ?? ''; | |
if (!isset($users[$username])) { | |
echo json_encode(['error' => "Username '$username' not found"]); | |
exit; | |
} | |
$challenge = randomBytes(32); | |
$token = bin2hex(randomBytes(16)); | |
$pending[$token] = [ | |
'type' => 'login', | |
'username' => $username, | |
'challenge' => b64encode($challenge), | |
'expires' => time() + 300 | |
]; | |
saveData($pending_file, $pending); | |
$options = [ | |
'challenge' => b64encode($challenge), | |
'allowCredentials' => [ | |
['type' => 'public-key', 'id' => $users[$username]['credentialId'], 'transports' => ['internal', 'hybrid']] | |
], // Specify transports as per WebAuthn Level 2 | |
'timeout' => 60000, | |
'userVerification' => 'preferred' | |
]; | |
echo json_encode(['options' => $options, 'token' => $token]); | |
exit; | |
} | |
// Handle AJAX responses for registration completion | |
if ($action === 'register_finish' && $_SERVER['REQUEST_METHOD'] === 'POST') { | |
header('Content-Type: application/json'); | |
$token = $_POST['token'] ?? ''; | |
if (!isset($pending[$token]) || $pending[$token]['type'] !== 'register' || $pending[$token]['expires'] < time()) { | |
echo json_encode(['error' => 'Invalid or expired registration request']); | |
exit; | |
} | |
$reg_data = $pending[$token]; | |
$username = $reg_data['username']; | |
$client_data_json = b64decode($_POST['clientData']); | |
$client_data = json_decode($client_data_json, true); | |
if ($client_data['challenge'] !== $reg_data['challenge']) { | |
echo json_encode(['error' => 'Invalid challenge in clientData']); | |
exit; | |
} | |
if ($client_data['origin'] !== (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST']) { | |
echo json_encode(['error' => 'Invalid origin in clientData: ' . $client_data['origin'] . ' vs ' . ((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'])]); | |
exit; | |
} | |
if ($client_data['type'] !== 'webauthn.create') { | |
echo json_encode(['error' => 'Invalid clientData type']); | |
exit; | |
} | |
$result = extractPublicKey($_POST['attestation']); | |
if (isset($result['error'])) { | |
echo json_encode(['error' => 'Failed to extract public key: ' . $result['error']]); | |
exit; | |
} | |
$users[$username] = [ | |
'credentialId' => $_POST['id'], | |
'publicKey' => $result['publicKey'], // Keep as binary, saveUsers will handle encoding | |
'aaguid' => $result['aaguid'], | |
'signCount' => $result['signCount'], | |
'registeredAt' => date('Y-m-d H:i:s') | |
]; | |
if (!saveUsers($users_file, $users)) { | |
$lastError = error_get_last(); | |
$errorMsg = 'Failed to save user data'; | |
if ($lastError) { | |
$errorMsg .= ': ' . $lastError['message']; | |
} | |
echo json_encode(['error' => $errorMsg]); | |
exit; | |
} | |
unset($pending[$token]); | |
saveData($pending_file, $pending); | |
$_SESSION['show_public_key'] = $result['publicKey']; | |
$_SESSION['show_username'] = $username; | |
echo json_encode(['success' => true]); | |
exit; | |
} | |
// Handle AJAX responses for login completion | |
if ($action === 'login_finish' && $_SERVER['REQUEST_METHOD'] === 'POST') { | |
header('Content-Type: application/json'); | |
$token = $_POST['token'] ?? ''; | |
if (!isset($pending[$token]) || $pending[$token]['type'] !== 'login' || $pending[$token]['expires'] < time()) { | |
echo json_encode(['error' => 'Invalid or expired login request']); | |
exit; | |
} | |
$login_data = $pending[$token]; | |
$username = $login_data['username']; | |
$userData = $users[$username] ?? null; | |
if (!$userData) { | |
echo json_encode(['error' => 'User data not found for ' . $username]); | |
exit; | |
} | |
$client_data_json = b64decode($_POST['clientData']); | |
$client_data = json_decode($client_data_json, true); | |
if ($client_data['challenge'] !== $login_data['challenge']) { | |
echo json_encode(['error' => 'Invalid challenge in clientData']); | |
exit; | |
} | |
if ($client_data['origin'] !== (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST']) { | |
echo json_encode(['error' => 'Invalid origin in clientData']); | |
exit; | |
} | |
if ($client_data['type'] !== 'webauthn.get') { | |
echo json_encode(['error' => 'Invalid clientData type']); | |
exit; | |
} | |
if ($_POST['id'] !== $userData['credentialId']) { | |
echo json_encode(['error' => 'Credential ID mismatch']); | |
exit; | |
} | |
$authData = b64decode($_POST['authData']); | |
$clientDataHash = hash('sha256', $client_data_json, true); | |
$signedData = $authData . $clientDataHash; | |
$signature = b64decode($_POST['signature']); | |
// Update signCount from authData | |
$newSignCount = unpack('N', substr($authData, 33, 4))[1]; | |
if ($newSignCount < $userData['signCount']) { | |
// This indicates a potential replay attack or credential cloning. | |
// For a simple demo, we just log and warn. In production, this should trigger stronger security measures. | |
error_log("Sign count for user {$username} decreased from {$userData['signCount']} to {$newSignCount}. Possible replay attack!"); | |
// Optionally, you might want to fail the login here: | |
// echo json_encode(['error' => 'Sign count anomaly detected.']); exit; | |
} | |
$users[$username]['signCount'] = $newSignCount; | |
saveUsers($users_file, $users); // Save updated signCount | |
if (!verifySignature($userData['publicKey'], $signedData, $signature)) { | |
echo json_encode(['error' => 'Signature verification failed']); | |
exit; | |
} | |
$_SESSION['user'] = $username; | |
unset($pending[$token]); | |
saveData($pending_file, $pending); | |
echo json_encode(['success' => true, 'message' => "Welcome $username! Signature verified."]); | |
exit; | |
} | |
if ($action === 'logout') { | |
unset($_SESSION['user']); | |
$_SESSION['msg'] = 'Logged out'; | |
header('Location: ?'); | |
exit; | |
} | |
if ($action === 'delete_credential' && $_SERVER['REQUEST_METHOD'] === 'POST') { | |
$username = $_POST['username'] ?? ''; | |
if (!isset($users[$username])) { | |
$_SESSION['msg'] = 'User not found.'; | |
header('Location: ?'); | |
exit; | |
} | |
// In a real application, you might want to challenge the user (e.g., re-authenticate) | |
// before allowing credential deletion. For this demo, direct deletion. | |
// Remove the public key and credential ID for this user | |
unset($users[$username]['credentialId']); | |
unset($users[$username]['publicKey']); | |
unset($users[$username]['aaguid']); | |
unset($users[$username]['signCount']); | |
// You might choose to delete the entire user entry if they have no other credentials | |
// For this demo, we'll keep the user entry but mark them as un-registered for passkey. | |
if (saveUsers($users_file, $users)) { | |
$_SESSION['msg'] = "Passkey for user '$username' removed successfully. User still exists but needs to register a new passkey to login."; | |
} else { | |
$_SESSION['msg'] = 'Failed to remove passkey.'; | |
} | |
header('Location: ?'); | |
exit; | |
} | |
if ($action === 'reset_users') { | |
if (saveUsers($users_file, [])) { | |
$_SESSION['msg'] = 'All users deleted successfully'; | |
} else { | |
$_SESSION['msg'] = 'Failed to reset users database'; | |
} | |
header('Location: ?'); | |
exit; | |
} | |
// Clean up expired tokens | |
foreach ($pending as $token => $data) { | |
if ($data['expires'] < time()) { | |
unset($pending[$token]); | |
} | |
} | |
saveData($pending_file, $pending); | |
?> | |
<!DOCTYPE html> | |
<html> | |
<head> | |
<title>Passkeys Demo</title> | |
</head> | |
<body> | |
<h1>Passkeys Demo</h1> | |
<?php if (isset($_SESSION['user'])): ?> | |
<p>Logged in as: <strong><?= htmlspecialchars($_SESSION['user']) ?></strong> | |
<a href="?action=logout">Logout</a></p> | |
<?php else: ?> | |
<h2>Register / Login</h2> | |
<p>Enter your username to either register a new passkey or login with an existing one.</p> | |
<input type="text" id="username" placeholder="Username" required> | |
<button onclick="handlePasskeyAction('register')">Register Passkey</button> | |
<button onclick="handlePasskeyAction('login')">Login with Passkey</button> | |
<?php endif; ?> | |
<?php if ($msg): ?> | |
<p><strong><?= htmlspecialchars($msg) ?></strong></p> | |
<?php endif; ?> | |
<div id="status" style="display:none;"></div> | |
<?php if (isset($_SESSION['show_public_key'])): ?> | |
<h3>Registration Successful for <?= htmlspecialchars($_SESSION['show_username']) ?>!</h3> | |
<h4>Extracted Public Key:</h4> | |
<pre><?= htmlspecialchars(formatPublicKey($_SESSION['show_public_key'])) ?></pre> | |
<?php unset($_SESSION['show_public_key'], $_SESSION['show_username']); ?> | |
<?php endif; ?> | |
<h3>Registered Users (<?= count($users) ?>):</h3> | |
<?php if (empty($users)): ?> | |
<p><em>No users registered yet.</em></p> | |
<?php else: ?> | |
<ul> | |
<?php foreach ($users as $username => $userData): ?> | |
<?php if (is_array($userData) && isset($userData['credentialId'])): ?> | |
<li> | |
<strong><?= htmlspecialchars($username) ?></strong> | |
<button onclick="deleteCredential('<?= htmlspecialchars($username) ?>')">Remove Passkey</button> | |
<br> | |
Registered: <?= htmlspecialchars($userData['registeredAt'] ?? 'Unknown') ?><br> | |
AAGUID: <?= htmlspecialchars($userData['aaguid'] ?? 'Unknown') ?><br> | |
Credential ID: <?= htmlspecialchars($userData['credentialId'] ?? 'N/A') ?><br> | |
Sign Count: <?= htmlspecialchars($userData['signCount'] ?? 'N/A') ?><br> | |
<strong>Public Key:</strong> | |
<pre><?= htmlspecialchars(formatPublicKey($userData['publicKey'])) ?></pre> | |
</li> | |
<?php endif; ?> | |
<?php endforeach; ?> | |
</ul> | |
<?php endif; ?> | |
<button onclick="if(confirm('Delete ALL users?')) location.href='?action=reset_users'">Reset All Users</button> | |
<hr> | |
<p>Code: <a href="https://gist.github.com/nst/f50b4774827c27c4de749742ef0ea046">https://gist.github.com/nst/f50b4774827c27c4de749742ef0ea046</a></p> | |
<p>Protocol and security smells: <a href="https://seriot.ch/pk/202508_passkeys.pdf">202508_passkeys.pdf</a></p> | |
<script> | |
function b64decode(s) { | |
return Uint8Array.from(atob(s.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0)); | |
} | |
function b64encode(buf) { | |
return btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=/g,''); | |
} | |
function togglePublicKey(username) { | |
const keyDiv = document.getElementById('key-' + username); | |
if (keyDiv.style.display === 'none') { | |
keyDiv.style.display = 'block'; | |
} else { | |
keyDiv.style.display = 'none'; | |
} | |
} | |
async function showStatus(message, isError = false, append = false) { | |
const statusDiv = document.getElementById('status'); | |
if (append) { | |
statusDiv.innerHTML += message; | |
} else { | |
statusDiv.innerHTML = message; | |
} | |
statusDiv.style.display = 'block'; | |
} | |
async function handlePasskeyAction(type) { | |
const username = document.getElementById('username').value; | |
if (!username) { | |
showStatus('Please enter a username.', true); | |
return; | |
} | |
showStatus('Preparing passkey ' + type + '...'); | |
try { | |
const response = await fetch(`?action=get_${type}_options`, { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |
body: `username=${encodeURIComponent(username)}` | |
}); | |
const data = await response.json(); | |
if (data.error) { | |
showStatus('Error: ' + data.error, true); | |
return; | |
} | |
const options = data.options; | |
const token = data.token; | |
showStatus('WebAuthn ' + type + ' options generated:\n<pre>' + JSON.stringify(options, null, 2) + '</pre>', false, false); | |
await new Promise(resolve => setTimeout(resolve, 1000)); | |
showStatus('<br>Prompting passkey ' + type + '...', false, true); | |
if (type === 'register') { | |
await createPasskey(options, token); | |
} else { | |
await usePasskey(options, token); | |
} | |
} catch (e) { | |
showStatus('Network or server error: ' + e.message, true); | |
} | |
} | |
async function createPasskey(options, token) { | |
try { | |
options.challenge = b64decode(options.challenge); | |
options.user.id = b64decode(options.user.id); | |
const cred = await navigator.credentials.create({publicKey: options}); | |
const response = await fetch('?action=register_finish', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |
body: new URLSearchParams({ | |
token: token, | |
id: cred.id, | |
attestation: b64encode(cred.response.attestationObject), | |
clientData: b64encode(cred.response.clientDataJSON) | |
}) | |
}); | |
const data = await response.json(); | |
if (data.success) { | |
window.location.href = '?'; // Reload to show success message and updated user list | |
} else { | |
showStatus('Registration failed: ' + data.error, true); | |
} | |
} catch(e) { | |
showStatus('Passkey creation error: ' + e.message, true); | |
} | |
} | |
async function usePasskey(options, token) { | |
try { | |
options.challenge = b64decode(options.challenge); | |
// The credential ID for allowCredentials must be a Uint8Array | |
if (options.allowCredentials && options.allowCredentials.length > 0) { | |
options.allowCredentials[0].id = b64decode(options.allowCredentials[0].id); | |
} else { | |
// For login, if no specific credential ID is allowed, | |
// the browser will allow discovery. This is usually preferred. | |
delete options.allowCredentials; // Remove if empty or not needed | |
} | |
const cred = await navigator.credentials.get({publicKey: options}); | |
const response = await fetch('?action=login_finish', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |
body: new URLSearchParams({ | |
token: token, | |
id: cred.id, | |
authData: b64encode(cred.response.authenticatorData), | |
clientData: b64encode(cred.response.clientDataJSON), | |
signature: b64encode(cred.response.signature) | |
}) | |
}); | |
const data = await response.json(); | |
if (data.success) { | |
window.location.href = '?'; // Reload to show success message and login status | |
} else { | |
showStatus('Login failed: ' + data.error, true); | |
} | |
} catch(e) { | |
showStatus('Passkey login error: ' + e.message, true); | |
} | |
} | |
function deleteCredential(username) { | |
if (confirm(`Are you sure you want to remove the passkey for user '${username}'? They will no longer be able to log in with a passkey unless they register a new one.`)) { | |
const form = document.createElement('form'); | |
form.method = 'POST'; | |
form.action = '?action=delete_credential'; | |
const input = document.createElement('input'); | |
input.type = 'hidden'; | |
input.name = 'username'; | |
input.value = username; | |
form.appendChild(input); | |
document.body.appendChild(form); | |
form.submit(); | |
} | |
} | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment