setAllowedOrigins(['https://' . $rpId, 'http://' . $rpId]); $this->validator = new AuthenticatorAssertionResponseValidator( $factory->requestCeremony() ); $attestationStatementSupportManager = new AttestationStatementSupportManager(); $serializerFactory = new WebauthnSerializerFactory($attestationStatementSupportManager); $this->serializer = $serializerFactory->create(); } /** * Verify a WebAuthn assertion * * @param string $assertionJson The JSON string from the client * @param string $challengeToken The hex challenge token stored in our DB * @param passkeys_o $passkey The passkey object from our DB * @param string $host The host (rpId) to verify against * @return bool */ public function verifyAssertion( string $assertionJson, string $challengeToken, passkeys_o $passkey, string $host ): bool { try { $publicKeyCredential = $this->serializer->deserialize($assertionJson, PublicKeyCredential::class, 'json'); $response = $publicKeyCredential->response; if (!$response instanceof AuthenticatorAssertionResponse) { throw new Exception('Not an assertion response'); } // Create PublicKeyCredentialSource from passkey object $source = $this->createSourceFromObject($passkey); // Prepare challenge (binary as expected by the WebAuthn lib) $challenge = hex2bin($challengeToken); // Create PublicKeyCredentialRequestOptions $options = PublicKeyCredentialRequestOptions::create( $challenge, $host ); // Check $this->validator->check( $source, $response, $options, $host, $source->userHandle ); // If it didn't throw, it's valid. Update sign count. // Note: validator updates source counter in v5 $passkey->sign_count->set($source->counter); return true; } catch (Exception $e) { // Log error $pk_hex = bin2hex($this->b64urlDecode((string)$passkey->public_key->value())); error_log("WebAuthn verification failed: " . $e->getMessage() . " (PK Hex: $pk_hex)"); throw new Exception("WebAuthn verification failed: " . $e->getMessage() . " (PK Hex: $pk_hex)"); } catch (ExceptionInterface $e) { throw new Exception('Serialization error: ' . $e->getMessage()); } } private function createSourceFromObject(passkeys_o $passkey): PublicKeyCredentialSource { $transports = $passkey->transports->value() ?: []; $credential_id_bin = $this->b64urlDecode((string)$passkey->credential_id->value()); $public_key_raw = $this->b64urlDecode((string)$passkey->public_key->value()); // Ensure the credential public key is in COSE (CBOR) format as expected by webauthn-lib $credentialPublicKey = $this->ensureCosePublicKey($public_key_raw, (string)$passkey->algorithm->value()); return new PublicKeyCredentialSource( $credential_id_bin, // publicKeyCredentialId (binary) 'public-key', $transports, 'none', // attestationType new EmptyTrustPath(), Uuid::fromString('00000000-0000-0000-0000-000000000000'), // aaguid $credentialPublicKey, // credentialPublicKey (COSE/CBOR binary) (string)$passkey->user_id->value(), // userHandle (int)$passkey->sign_count->value() ); } /** * Base64URL decode with padding handling */ public function b64urlDecode(string $data): string { $b64 = strtr($data, '-_', '+/'); $pad = strlen($b64) % 4; if ($pad) { $b64 .= str_repeat('=', 4 - $pad); } $decoded = base64_decode($b64, true); if ($decoded === false) { // As a last resort, try non-strict decode $decoded = base64_decode($b64, false); } return $decoded === false ? '' : $decoded; } /** * Detects if the given data looks like a PEM public key (ASCII) or DER SPKI (ASN.1 sequence) */ private function looksLikePemOrDer(string $data): bool { if ($data === '') { return false; } if (str_contains($data, '-----BEGIN')) { return true; } // DER SEQUENCE usually starts with 0x30 return isset($data[0]) && ord($data[0]) === 0x30; } /** * Convert PEM/DER EC public key to COSE EC2 key (CBOR binary). If already COSE, return as-is. * Supports ES256/P-256. Other curves will throw. */ public function ensureCosePublicKey(string $data, string $algorithm): string { // If data already looks like COSE (CBOR map) with small leading major type 5 (map) // we check if it is indeed a valid CBOR map with at least the 'kty' (1) key. if ($data !== '' && (ord($data[0]) >= 0xA0 && ord($data[0]) <= 0xBF)) { try { $stream = new \Webauthn\StringStream($data); $object = \CBOR\Decoder::create()->decode($stream); if ($object instanceof \CBOR\Normalizable) { $normalized = $object->normalize(); if (is_array($normalized)) { if (isset($normalized[1])) { return $data; // valid COSE map } // Some clients send the full attestation object instead of just the public key. // We detect this by checking for 'authData' and 'fmt' keys in the CBOR map. if (isset($normalized['authData']) && isset($normalized['fmt']) && is_string($normalized['authData'])) { $authData = $normalized['authData']; if (strlen($authData) >= 37) { $flags = ord($authData[32]); $offset = 37; // RP ID Hash(32) + Flags(1) + Counter(4) if (($flags & 0x40) === 0x40) { // Attested credential data present $offset += 16; // AAGUID if (strlen($authData) >= $offset + 2) { $l = (ord($authData[$offset]) << 8) | ord($authData[$offset + 1]); $offset += 2 + $l; // Credential ID if (strlen($authData) > $offset) { $publicKeyData = substr($authData, $offset); try { // Extract just the public key (which is a CBOR object) $pkStream = new \Webauthn\StringStream($publicKeyData); $pkObject = \CBOR\Decoder::create()->decode($pkStream); return (new Encoder())->encode($pkObject); } catch (Exception $e) { // Not a valid CBOR object at the expected offset; fall through } } } } } } } } } catch (Exception $e) { // Not a valid CBOR map or missing kty; proceed to PEM/DER checks } } if (!$this->looksLikePemOrDer($data)) { // Unknown binary; try returning as-is and let downstream throw if invalid. return $data; } // Normalize to PEM string $pem = $data; if (!str_contains($pem, '-----BEGIN')) { $pem = "-----BEGIN PUBLIC KEY-----\r\n" . chunk_split(base64_encode($data), 64, "\r\n") . "-----END PUBLIC KEY-----\r\n"; } $key = @openssl_pkey_get_public($pem); if ($key === false) { throw new Exception('Invalid public key material (PEM/DER parse failed)'); } $details = openssl_pkey_get_details($key); if (!is_array($details) || !isset($details['type'])) { throw new Exception('Could not extract public key details'); } if ((int)$details['type'] !== OPENSSL_KEYTYPE_EC) { throw new Exception('Unsupported key type for WebAuthn (expected EC)'); } $curve = $details['ec']['curve_name'] ?? null; $point = $details['ec']['public_key'] ?? null; $x = $details['ec']['x'] ?? null; $y = $details['ec']['y'] ?? null; if ($x === null || $y === null) { // Try to parse uncompressed point (0x04 || X || Y) if (!is_string($point) || $point === '' || $point[0] !== "\x04") { throw new Exception('Unable to extract EC public key coordinates'); } $coordLen = (strlen($point) - 1) / 2; if ($coordLen <= 0) { throw new Exception('Invalid EC public key point'); } $x = substr($point, 1, (int)$coordLen); $y = substr($point, 1 + (int)$coordLen, (int)$coordLen); } // Map curve to COSE crv id $crv = 1; // default P-256 if (is_string($curve)) { $lc = strtolower($curve); if (str_contains($lc, 'secp256') || str_contains($lc, 'prime256') || str_contains($lc, 'p-256')) { $crv = 1; // P-256 } elseif (str_contains($lc, 'secp384') || str_contains($lc, 'p-384')) { $crv = 2; // P-384 } elseif (str_contains($lc, 'secp521') || str_contains($lc, 'p-521')) { $crv = 3; // P-521 } else { throw new Exception('Unsupported EC curve: ' . $curve); } } // Map algorithm string to COSE alg id (defaults to ES256) $alg = -7; // ES256 $algStr = strtoupper((string)$algorithm); if ($algStr === 'ES384') { $alg = -35; } elseif ($algStr === 'ES512') { $alg = -36; } // Build COSE key map and CBOR-encode it $cose = [ 1 => 2, // kty: EC2 3 => $alg, // alg -1 => $crv, // crv -2 => $x, // x-coordinate (bytes) -3 => $y, // y-coordinate (bytes) ]; $encoder = new Encoder(); return $encoder->encode($cose); } }