| 1 |
<?php |
| 2 |
// WebAuthn / passkey verification, hand-rolled to keep the project's "no |
| 3 |
// third-party code" rule. Only what a single-user site needs: register a |
| 4 |
// credential, then verify assertions against the stored public key. |
| 5 |
// |
| 6 |
// Attestation is deliberately NOT verified. Attestation only proves which |
| 7 |
// make and model of authenticator was used, which matters when an |
| 8 |
// organisation must restrict employees to approved hardware. Here the owner |
| 9 |
// chooses their own device, so any authenticator they can use is acceptable, |
| 10 |
// and skipping it avoids shipping a CA bundle of vendor roots. |
| 11 |
require_once __DIR__ . '/auth.php'; |
| 12 |
|
| 13 |
function b64url_encode(string $bin): string |
| 14 |
{ |
| 15 |
return rtrim(strtr(base64_encode($bin), '+/', '-_'), '='); |
| 16 |
} |
| 17 |
|
| 18 |
function b64url_decode(string $txt): string |
| 19 |
{ |
| 20 |
return (string)base64_decode(strtr($txt, '-_', '+/'), true); |
| 21 |
} |
| 22 |
|
| 23 |
// The Relying Party id a credential is bound to: the host without its port. |
| 24 |
// Credentials are scoped to this, so a passkey made on localhost will not |
| 25 |
// work on the live domain and vice versa — each host needs its own. |
| 26 |
function webauthn_rp_id(): string |
| 27 |
{ |
| 28 |
$host = (string)($_SERVER['HTTP_HOST'] ?? 'localhost'); |
| 29 |
return preg_replace('/:\d+$/', '', $host); |
| 30 |
} |
| 31 |
|
| 32 |
// The exact origin the browser will report in clientDataJSON, port included. |
| 33 |
function webauthn_origin(): string |
| 34 |
{ |
| 35 |
return (is_https() ? 'https://' : 'http://') . ($_SERVER['HTTP_HOST'] ?? 'localhost'); |
| 36 |
} |
| 37 |
|
| 38 |
// Passkeys need a secure context. Browsers make one exception, localhost, |
| 39 |
// which is what lets this work under MAMP without a certificate. |
| 40 |
function webauthn_available(): bool |
| 41 |
{ |
| 42 |
$host = webauthn_rp_id(); |
| 43 |
return is_https() || $host === 'localhost' || $host === '127.0.0.1' || $host === '::1'; |
| 44 |
} |
| 45 |
|
| 46 |
/* ---------- CBOR ---------- */ |
| 47 |
|
| 48 |
// Length/value field shared by every CBOR major type. |
| 49 |
function cbor_arg(string $d, int &$o, int $info): int |
| 50 |
{ |
| 51 |
if ($info < 24) { |
| 52 |
return $info; |
| 53 |
} |
| 54 |
if ($info === 24) { |
| 55 |
return ord($d[$o++]); |
| 56 |
} |
| 57 |
if ($info === 25) { |
| 58 |
$v = unpack('n', substr($d, $o, 2))[1]; |
| 59 |
$o += 2; |
| 60 |
return $v; |
| 61 |
} |
| 62 |
if ($info === 26) { |
| 63 |
$v = unpack('N', substr($d, $o, 4))[1]; |
| 64 |
$o += 4; |
| 65 |
return $v; |
| 66 |
} |
| 67 |
if ($info === 27) { |
| 68 |
$v = unpack('J', substr($d, $o, 8))[1]; |
| 69 |
$o += 8; |
| 70 |
return $v; |
| 71 |
} |
| 72 |
throw new RuntimeException('Bad CBOR length marker ' . $info); |
| 73 |
} |
| 74 |
|
| 75 |
// Decodes the CBOR subset WebAuthn actually uses. $o advances past the |
| 76 |
// decoded item, which is how the authenticator-data parser finds where the |
| 77 |
// variable-length public key ends. |
| 78 |
function cbor_decode(string $d, int &$o = 0) |
| 79 |
{ |
| 80 |
if ($o >= strlen($d)) { |
| 81 |
throw new RuntimeException('CBOR ended early'); |
| 82 |
} |
| 83 |
$b = ord($d[$o++]); |
| 84 |
$major = $b >> 5; |
| 85 |
$info = $b & 0x1f; |
| 86 |
|
| 87 |
if ($major === 7) { |
| 88 |
// Simple values. Floats never appear in COSE keys or attestation. |
| 89 |
if ($info === 20) { |
| 90 |
return false; |
| 91 |
} |
| 92 |
if ($info === 21) { |
| 93 |
return true; |
| 94 |
} |
| 95 |
if ($info === 22 || $info === 23) { |
| 96 |
return null; |
| 97 |
} |
| 98 |
throw new RuntimeException('Unsupported CBOR simple value ' . $info); |
| 99 |
} |
| 100 |
|
| 101 |
$arg = cbor_arg($d, $o, $info); |
| 102 |
|
| 103 |
switch ($major) { |
| 104 |
case 0: |
| 105 |
return $arg; |
| 106 |
case 1: |
| 107 |
return -1 - $arg; |
| 108 |
case 2: |
| 109 |
case 3: |
| 110 |
$s = substr($d, $o, $arg); |
| 111 |
$o += $arg; |
| 112 |
return $s; |
| 113 |
case 4: |
| 114 |
$a = []; |
| 115 |
for ($i = 0; $i < $arg; $i++) { |
| 116 |
$a[] = cbor_decode($d, $o); |
| 117 |
} |
| 118 |
return $a; |
| 119 |
case 5: |
| 120 |
$m = []; |
| 121 |
for ($i = 0; $i < $arg; $i++) { |
| 122 |
$k = cbor_decode($d, $o); |
| 123 |
$m[$k] = cbor_decode($d, $o); |
| 124 |
} |
| 125 |
return $m; |
| 126 |
} |
| 127 |
throw new RuntimeException('Unsupported CBOR major type ' . $major); |
| 128 |
} |
| 129 |
|
| 130 |
/* ---------- DER ---------- */ |
| 131 |
|
| 132 |
function der_len(int $n): string |
| 133 |
{ |
| 134 |
if ($n < 128) { |
| 135 |
return chr($n); |
| 136 |
} |
| 137 |
$bytes = ltrim(pack('N', $n), "\x00"); |
| 138 |
return chr(0x80 | strlen($bytes)) . $bytes; |
| 139 |
} |
| 140 |
|
| 141 |
function der_tlv(int $tag, string $value): string |
| 142 |
{ |
| 143 |
return chr($tag) . der_len(strlen($value)) . $value; |
| 144 |
} |
| 145 |
|
| 146 |
// DER INTEGERs are signed, so a leading bit of 1 needs a 0x00 pad or the |
| 147 |
// value reads as negative. |
| 148 |
function der_int(string $bytes): string |
| 149 |
{ |
| 150 |
$bytes = ltrim($bytes, "\x00"); |
| 151 |
if ($bytes === '') { |
| 152 |
$bytes = "\x00"; |
| 153 |
} |
| 154 |
if (ord($bytes[0]) & 0x80) { |
| 155 |
$bytes = "\x00" . $bytes; |
| 156 |
} |
| 157 |
return der_tlv(0x02, $bytes); |
| 158 |
} |
| 159 |
|
| 160 |
// A COSE public key (RFC 8152) as a PEM SubjectPublicKeyInfo, which is what |
| 161 |
// openssl_verify() wants. ES256 and RS256 cover every mainstream passkey |
| 162 |
// provider — Apple, Google, Windows Hello, and the FIDO2 keys. |
| 163 |
function cose_to_pem(array $cose): string |
| 164 |
{ |
| 165 |
$kty = $cose[1] ?? null; |
| 166 |
$alg = $cose[3] ?? null; |
| 167 |
|
| 168 |
if ($kty === 2) { // Elliptic curve |
| 169 |
if ($alg !== -7) { |
| 170 |
throw new RuntimeException('Unsupported EC algorithm ' . var_export($alg, true)); |
| 171 |
} |
| 172 |
if (($cose[-1] ?? null) !== 1) { |
| 173 |
throw new RuntimeException('Only the P-256 curve is supported'); |
| 174 |
} |
| 175 |
$x = str_pad((string)($cose[-2] ?? ''), 32, "\x00", STR_PAD_LEFT); |
| 176 |
$y = str_pad((string)($cose[-3] ?? ''), 32, "\x00", STR_PAD_LEFT); |
| 177 |
$algId = der_tlv(0x30, hex2bin('06072a8648ce3d0201') . hex2bin('06082a8648ce3d030107')); |
| 178 |
$point = der_tlv(0x03, "\x00" . "\x04" . $x . $y); // 0x04 = uncompressed |
| 179 |
$spki = der_tlv(0x30, $algId . $point); |
| 180 |
} elseif ($kty === 3) { // RSA |
| 181 |
if ($alg !== -257) { |
| 182 |
throw new RuntimeException('Unsupported RSA algorithm ' . var_export($alg, true)); |
| 183 |
} |
| 184 |
$n = (string)($cose[-1] ?? ''); |
| 185 |
$e = (string)($cose[-2] ?? ''); |
| 186 |
$algId = der_tlv(0x30, hex2bin('06092a864886f70d010101') . "\x05\x00"); // rsaEncryption, NULL params |
| 187 |
$inner = der_tlv(0x30, der_int($n) . der_int($e)); |
| 188 |
$spki = der_tlv(0x30, $algId . der_tlv(0x03, "\x00" . $inner)); |
| 189 |
} else { |
| 190 |
throw new RuntimeException('Unsupported key type ' . var_export($kty, true)); |
| 191 |
} |
| 192 |
|
| 193 |
return "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($spki), 64, "\n") . "-----END PUBLIC KEY-----\n"; |
| 194 |
} |
| 195 |
|
| 196 |
/* ---------- authenticator data ---------- */ |
| 197 |
|
| 198 |
// authData is a packed struct: 32-byte RP id hash, 1 flag byte, 4-byte |
| 199 |
// signature counter, and — when the AT flag is set — the new credential. |
| 200 |
function parse_auth_data(string $ad): array |
| 201 |
{ |
| 202 |
if (strlen($ad) < 37) { |
| 203 |
throw new RuntimeException('Authenticator data too short'); |
| 204 |
} |
| 205 |
$out = [ |
| 206 |
'rp_id_hash' => substr($ad, 0, 32), |
| 207 |
'flags' => ord($ad[32]), |
| 208 |
'sign_count' => unpack('N', substr($ad, 33, 4))[1], |
| 209 |
]; |
| 210 |
if ($out['flags'] & 0x40) { // AT: attested credential data present |
| 211 |
$len = unpack('n', substr($ad, 53, 2))[1]; // 16 bytes AAGUID first |
| 212 |
$out['credential_id'] = substr($ad, 55, $len); |
| 213 |
$offset = 55 + $len; |
| 214 |
$out['cose_key'] = cbor_decode($ad, $offset); |
| 215 |
} |
| 216 |
return $out; |
| 217 |
} |
| 218 |
|
| 219 |
// Checks the parts of clientDataJSON that are the same for registration and |
| 220 |
// login: that the browser signed OUR challenge, for OUR origin, in the |
| 221 |
// ceremony we asked for. Getting any of these wrong is how a WebAuthn |
| 222 |
// implementation ends up accepting a replayed or cross-site assertion. |
| 223 |
function verify_client_data(string $json, string $expectedType, string $challenge): void |
| 224 |
{ |
| 225 |
$cd = json_decode($json, true); |
| 226 |
if (!is_array($cd)) { |
| 227 |
throw new RuntimeException('clientDataJSON is not valid JSON'); |
| 228 |
} |
| 229 |
if (($cd['type'] ?? '') !== $expectedType) { |
| 230 |
throw new RuntimeException('Wrong ceremony type'); |
| 231 |
} |
| 232 |
if (!hash_equals(b64url_encode($challenge), (string)($cd['challenge'] ?? ''))) { |
| 233 |
throw new RuntimeException('Challenge does not match'); |
| 234 |
} |
| 235 |
if (!hash_equals(webauthn_origin(), (string)($cd['origin'] ?? ''))) { |
| 236 |
throw new RuntimeException('Origin does not match'); |
| 237 |
} |
| 238 |
} |
| 239 |
|
| 240 |
// Registration: verify the ceremony and return the row to store. |
| 241 |
function webauthn_verify_registration(string $clientDataJSON, string $attestationObject, string $challenge): array |
| 242 |
{ |
| 243 |
verify_client_data($clientDataJSON, 'webauthn.create', $challenge); |
| 244 |
|
| 245 |
$att = cbor_decode($attestationObject); |
| 246 |
if (!isset($att['authData'])) { |
| 247 |
throw new RuntimeException('Attestation object has no authenticator data'); |
| 248 |
} |
| 249 |
$ad = parse_auth_data($att['authData']); |
| 250 |
|
| 251 |
if (!hash_equals(hash('sha256', webauthn_rp_id(), true), $ad['rp_id_hash'])) { |
| 252 |
throw new RuntimeException('Credential is for a different site'); |
| 253 |
} |
| 254 |
if (!($ad['flags'] & 0x01)) { |
| 255 |
throw new RuntimeException('User presence was not verified'); |
| 256 |
} |
| 257 |
if (empty($ad['credential_id']) || empty($ad['cose_key'])) { |
| 258 |
throw new RuntimeException('No credential in the attestation'); |
| 259 |
} |
| 260 |
|
| 261 |
return [ |
| 262 |
'credential_id' => b64url_encode($ad['credential_id']), |
| 263 |
'public_key' => cose_to_pem($ad['cose_key']), |
| 264 |
'sign_count' => $ad['sign_count'], |
| 265 |
]; |
| 266 |
} |
| 267 |
|
| 268 |
// Login: verify the assertion signature against the stored public key. |
| 269 |
// The signed message is authData concatenated with the SHA-256 of the client |
| 270 |
// data — never the client data itself. |
| 271 |
function webauthn_verify_assertion( |
| 272 |
string $clientDataJSON, |
| 273 |
string $authenticatorData, |
| 274 |
string $signature, |
| 275 |
string $challenge, |
| 276 |
string $pem, |
| 277 |
int $storedCount |
| 278 |
): int { |
| 279 |
verify_client_data($clientDataJSON, 'webauthn.get', $challenge); |
| 280 |
$ad = parse_auth_data($authenticatorData); |
| 281 |
|
| 282 |
if (!hash_equals(hash('sha256', webauthn_rp_id(), true), $ad['rp_id_hash'])) { |
| 283 |
throw new RuntimeException('Assertion is for a different site'); |
| 284 |
} |
| 285 |
if (!($ad['flags'] & 0x01)) { |
| 286 |
throw new RuntimeException('User presence was not verified'); |
| 287 |
} |
| 288 |
|
| 289 |
$signed = $authenticatorData . hash('sha256', $clientDataJSON, true); |
| 290 |
if (openssl_verify($signed, $signature, $pem, OPENSSL_ALGO_SHA256) !== 1) { |
| 291 |
throw new RuntimeException('Signature does not verify'); |
| 292 |
} |
| 293 |
|
| 294 |
// A counter that failed to advance suggests a cloned authenticator. Many |
| 295 |
// passkey providers (Apple and Google among them) always report 0 because |
| 296 |
// the credential is synced across devices by design, so the check only |
| 297 |
// applies when the authenticator actually keeps a counter. |
| 298 |
if ($storedCount > 0 && $ad['sign_count'] > 0 && $ad['sign_count'] <= $storedCount) { |
| 299 |
throw new RuntimeException('Signature counter went backwards'); |
| 300 |
} |
| 301 |
return $ad['sign_count']; |
| 302 |
} |
| 303 |
|