= strlen($d)) { throw new RuntimeException('CBOR ended early'); } $b = ord($d[$o++]); $major = $b >> 5; $info = $b & 0x1f; if ($major === 7) { // Simple values. Floats never appear in COSE keys or attestation. if ($info === 20) { return false; } if ($info === 21) { return true; } if ($info === 22 || $info === 23) { return null; } throw new RuntimeException('Unsupported CBOR simple value ' . $info); } $arg = cbor_arg($d, $o, $info); switch ($major) { case 0: return $arg; case 1: return -1 - $arg; case 2: case 3: $s = substr($d, $o, $arg); $o += $arg; return $s; case 4: $a = []; for ($i = 0; $i < $arg; $i++) { $a[] = cbor_decode($d, $o); } return $a; case 5: $m = []; for ($i = 0; $i < $arg; $i++) { $k = cbor_decode($d, $o); $m[$k] = cbor_decode($d, $o); } return $m; } throw new RuntimeException('Unsupported CBOR major type ' . $major); } /* ---------- DER ---------- */ function der_len(int $n): string { if ($n < 128) { return chr($n); } $bytes = ltrim(pack('N', $n), "\x00"); return chr(0x80 | strlen($bytes)) . $bytes; } function der_tlv(int $tag, string $value): string { return chr($tag) . der_len(strlen($value)) . $value; } // DER INTEGERs are signed, so a leading bit of 1 needs a 0x00 pad or the // value reads as negative. function der_int(string $bytes): string { $bytes = ltrim($bytes, "\x00"); if ($bytes === '') { $bytes = "\x00"; } if (ord($bytes[0]) & 0x80) { $bytes = "\x00" . $bytes; } return der_tlv(0x02, $bytes); } // A COSE public key (RFC 8152) as a PEM SubjectPublicKeyInfo, which is what // openssl_verify() wants. ES256 and RS256 cover every mainstream passkey // provider — Apple, Google, Windows Hello, and the FIDO2 keys. function cose_to_pem(array $cose): string { $kty = $cose[1] ?? null; $alg = $cose[3] ?? null; if ($kty === 2) { // Elliptic curve if ($alg !== -7) { throw new RuntimeException('Unsupported EC algorithm ' . var_export($alg, true)); } if (($cose[-1] ?? null) !== 1) { throw new RuntimeException('Only the P-256 curve is supported'); } $x = str_pad((string)($cose[-2] ?? ''), 32, "\x00", STR_PAD_LEFT); $y = str_pad((string)($cose[-3] ?? ''), 32, "\x00", STR_PAD_LEFT); $algId = der_tlv(0x30, hex2bin('06072a8648ce3d0201') . hex2bin('06082a8648ce3d030107')); $point = der_tlv(0x03, "\x00" . "\x04" . $x . $y); // 0x04 = uncompressed $spki = der_tlv(0x30, $algId . $point); } elseif ($kty === 3) { // RSA if ($alg !== -257) { throw new RuntimeException('Unsupported RSA algorithm ' . var_export($alg, true)); } $n = (string)($cose[-1] ?? ''); $e = (string)($cose[-2] ?? ''); $algId = der_tlv(0x30, hex2bin('06092a864886f70d010101') . "\x05\x00"); // rsaEncryption, NULL params $inner = der_tlv(0x30, der_int($n) . der_int($e)); $spki = der_tlv(0x30, $algId . der_tlv(0x03, "\x00" . $inner)); } else { throw new RuntimeException('Unsupported key type ' . var_export($kty, true)); } return "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($spki), 64, "\n") . "-----END PUBLIC KEY-----\n"; } /* ---------- authenticator data ---------- */ // authData is a packed struct: 32-byte RP id hash, 1 flag byte, 4-byte // signature counter, and — when the AT flag is set — the new credential. function parse_auth_data(string $ad): array { if (strlen($ad) < 37) { throw new RuntimeException('Authenticator data too short'); } $out = [ 'rp_id_hash' => substr($ad, 0, 32), 'flags' => ord($ad[32]), 'sign_count' => unpack('N', substr($ad, 33, 4))[1], ]; if ($out['flags'] & 0x40) { // AT: attested credential data present $len = unpack('n', substr($ad, 53, 2))[1]; // 16 bytes AAGUID first $out['credential_id'] = substr($ad, 55, $len); $offset = 55 + $len; $out['cose_key'] = cbor_decode($ad, $offset); } return $out; } // Checks the parts of clientDataJSON that are the same for registration and // login: that the browser signed OUR challenge, for OUR origin, in the // ceremony we asked for. Getting any of these wrong is how a WebAuthn // implementation ends up accepting a replayed or cross-site assertion. function verify_client_data(string $json, string $expectedType, string $challenge): void { $cd = json_decode($json, true); if (!is_array($cd)) { throw new RuntimeException('clientDataJSON is not valid JSON'); } if (($cd['type'] ?? '') !== $expectedType) { throw new RuntimeException('Wrong ceremony type'); } if (!hash_equals(b64url_encode($challenge), (string)($cd['challenge'] ?? ''))) { throw new RuntimeException('Challenge does not match'); } if (!hash_equals(webauthn_origin(), (string)($cd['origin'] ?? ''))) { throw new RuntimeException('Origin does not match'); } } // Registration: verify the ceremony and return the row to store. function webauthn_verify_registration(string $clientDataJSON, string $attestationObject, string $challenge): array { verify_client_data($clientDataJSON, 'webauthn.create', $challenge); $att = cbor_decode($attestationObject); if (!isset($att['authData'])) { throw new RuntimeException('Attestation object has no authenticator data'); } $ad = parse_auth_data($att['authData']); if (!hash_equals(hash('sha256', webauthn_rp_id(), true), $ad['rp_id_hash'])) { throw new RuntimeException('Credential is for a different site'); } if (!($ad['flags'] & 0x01)) { throw new RuntimeException('User presence was not verified'); } if (empty($ad['credential_id']) || empty($ad['cose_key'])) { throw new RuntimeException('No credential in the attestation'); } return [ 'credential_id' => b64url_encode($ad['credential_id']), 'public_key' => cose_to_pem($ad['cose_key']), 'sign_count' => $ad['sign_count'], ]; } // Login: verify the assertion signature against the stored public key. // The signed message is authData concatenated with the SHA-256 of the client // data — never the client data itself. function webauthn_verify_assertion( string $clientDataJSON, string $authenticatorData, string $signature, string $challenge, string $pem, int $storedCount ): int { verify_client_data($clientDataJSON, 'webauthn.get', $challenge); $ad = parse_auth_data($authenticatorData); if (!hash_equals(hash('sha256', webauthn_rp_id(), true), $ad['rp_id_hash'])) { throw new RuntimeException('Assertion is for a different site'); } if (!($ad['flags'] & 0x01)) { throw new RuntimeException('User presence was not verified'); } $signed = $authenticatorData . hash('sha256', $clientDataJSON, true); if (openssl_verify($signed, $signature, $pem, OPENSSL_ALGO_SHA256) !== 1) { throw new RuntimeException('Signature does not verify'); } // A counter that failed to advance suggests a cloned authenticator. Many // passkey providers (Apple and Google among them) always report 0 because // the credential is synced across devices by design, so the check only // applies when the authenticator actually keeps a counter. if ($storedCount > 0 && $ad['sign_count'] > 0 && $ad['sign_count'] <= $storedCount) { throw new RuntimeException('Signature counter went backwards'); } return $ad['sign_count']; }