<?php
/**
 * Self-contained FIDO2 / WebAuthn verifier.
 *
 * Implements the verification side of the WebAuthn ceremony without any
 * external dependencies. Supports:
 *  - Registration (attestation statement format "none" only).
 *  - Authentication (assertion) with signature verification via OpenSSL.
 *  - COSE EC2 (P-256) and RSA public keys.
 *  - CBOR decoding, authenticator data parsing, and client data validation.
 *
 * @package ZA_Creative_Login_Shield
 */

defined( 'ABSPATH' ) || exit;

/**
 * Verifies WebAuthn registration and assertion payloads.
 */
class ZACLS_WebAuthn_Verifier {

	/**
	 * Decode a base64url string.
	 *
	 * @param string $data Base64url encoded string.
	 * @return string Raw binary data.
	 */
	public static function base64url_decode( $data ) {
		$data = strtr( $data, '-_', '+/' );
		$data = str_pad( $data, strlen( $data ) % 4 ? strlen( $data ) + ( 4 - strlen( $data ) % 4 ) : 0, '=', STR_PAD_RIGHT );
		return base64_decode( $data, true );
	}

	/**
	 * Encode a binary string as base64url.
	 *
	 * @param string $data Raw binary data.
	 * @return string Base64url encoded string.
	 */
	public static function base64url_encode( $data ) {
		return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' );
	}

	/**
	 * Decode a CBOR value from a binary payload.
	 *
	 * @param string $data   The CBOR payload.
	 * @param int    $offset Byte offset, updated as the value is consumed.
	 * @param int    $depth  Current nesting depth (prevents stack abuse).
	 * @return mixed Decoded PHP value (int, string, array, bool, null).
	 * @throws Exception When the payload is malformed or unsupported.
	 */
	private static function cbor_decode( $data, &$offset, $depth ) {
		if ( $depth > 10 ) {
			throw new Exception( 'CBOR nesting too deep.' );
		}
		if ( ! isset( $data[ $offset ] ) ) {
			throw new Exception( 'CBOR payload truncated.' );
		}

		$initial    = ord( $data[ $offset ] );
		$offset++;
		$major      = ( $initial >> 5 ) & 0x07;
		$additional = $initial & 0x1F;
		$length     = self::cbor_length( $data, $offset, $additional );

		switch ( $major ) {
			case 0:
				return $length;

			case 1:
				return -1 - $length;

			case 2:
			case 3:
				if ( $offset + $length > strlen( $data ) ) {
					throw new Exception( 'CBOR string out of bounds.' );
				}
				$value = substr( $data, $offset, $length );
				$offset += $length;
				return $value;

			case 4:
				$items = array();
				for ( $i = 0; $i < $length; $i++ ) {
					$items[] = self::cbor_decode( $data, $offset, $depth + 1 );
				}
				return $items;

			case 5:
				$map = array();
				for ( $i = 0; $i < $length; $i++ ) {
					$key          = self::cbor_decode( $data, $offset, $depth + 1 );
					$map[ $key ] = self::cbor_decode( $data, $offset, $depth + 1 );
				}
				return $map;

			case 6:
				// Tags: the tag wraps the following value. Skip the tag and return the value.
				return self::cbor_decode( $data, $offset, $depth + 1 );

			case 7:
				switch ( $additional ) {
					case 20:
						return false;
					case 21:
						return true;
					case 22:
						return null;
					case 25:
						self::skip_bytes( $data, $offset, 2 );
						return 0.0;
					case 26:
						self::skip_bytes( $data, $offset, 4 );
						return 0.0;
					case 27:
						self::skip_bytes( $data, $offset, 8 );
						return 0.0;
					default:
						throw new Exception( 'Unsupported CBOR simple value.' );
				}

			default:
				throw new Exception( 'Unsupported CBOR major type.' );
		}
	}

	/**
	 * Read the length field for a CBOR item.
	 *
	 * @param string $data       The CBOR payload.
	 * @param int    $offset     Byte offset, updated as bytes are consumed.
	 * @param int    $additional The additional info value.
	 * @return int Length.
	 * @throws Exception When the encoding is unsupported.
	 */
	private static function cbor_length( $data, &$offset, $additional ) {
		if ( $additional < 24 ) {
			return $additional;
		}
		if ( 24 === $additional ) {
			return self::read_length_bytes( $data, $offset, 1 );
		}
		if ( 25 === $additional ) {
			return self::read_length_bytes( $data, $offset, 2 );
		}
		if ( 26 === $additional ) {
			return self::read_length_bytes( $data, $offset, 4 );
		}
		if ( 27 === $additional ) {
			return self::read_length_bytes( $data, $offset, 8 );
		}
		if ( 31 === $additional ) {
			throw new Exception( 'Indefinite length CBOR is not supported.' );
		}
		throw new Exception( 'Unsupported CBOR length encoding.' );
	}

	/**
	 * Read a big-endian integer of the given byte width.
	 *
	 * @param string $data  The CBOR payload.
	 * @param int    $offset Byte offset, updated as bytes are consumed.
	 * @param int    $bytes  Number of bytes to read.
	 * @return int Value.
	 * @throws Exception When the payload is truncated.
	 */
	private static function read_length_bytes( $data, &$offset, $bytes ) {
		if ( $offset + $bytes > strlen( $data ) ) {
			throw new Exception( 'CBOR length out of bounds.' );
		}
		$chunk = substr( $data, $offset, $bytes );
		$offset += $bytes;
		$value = 0;
		for ( $i = 0; $i < $bytes; $i++ ) {
			$value = ( $value << 8 ) | ord( $chunk[ $i ] );
		}
		return $value;
	}

	/**
	 * Skip a fixed number of bytes (used for unsupported CBOR values).
	 *
	 * @param string $data  The CBOR payload.
	 * @param int    $offset Byte offset, updated.
	 * @param int    $bytes  Number of bytes to skip.
	 * @throws Exception When the payload is truncated.
	 */
	private static function skip_bytes( $data, &$offset, $bytes ) {
		if ( $offset + $bytes > strlen( $data ) ) {
			throw new Exception( 'CBOR value out of bounds.' );
		}
		$offset += $bytes;
	}

	/**
	 * Parse the authenticator data structure.
	 *
	 * @param string $auth_data Raw authenticator data bytes.
	 * @return array|null Parsed structure or null on malformed input.
	 */
	public static function parse_auth_data( $auth_data ) {
		if ( ! is_string( $auth_data ) || strlen( $auth_data ) < 37 ) {
			return null;
		}

		$flags      = ord( $auth_data[32] );
		$unpacked   = unpack( 'N', substr( $auth_data, 33, 4 ) );
		$sign_count = isset( $unpacked[1] ) ? (int) $unpacked[1] : 0;

		$result = array(
			'rp_id_hash'    => substr( $auth_data, 0, 32 ),
			'flags'         => $flags,
			'sign_count'    => $sign_count,
			'aaguid'        => '',
			'credential_id' => '',
			'cose_key'      => null,
		);

		if ( $flags & 0x40 ) { // AT flag set (attested credential data present).
			$offset = 37;
			if ( strlen( $auth_data ) < $offset + 18 ) {
				return null;
			}
			$result['aaguid'] = substr( $auth_data, $offset, 16 );
			$offset += 16;
			$cred_len_unpacked = unpack( 'n', substr( $auth_data, $offset, 2 ) );
			$cred_len          = isset( $cred_len_unpacked[1] ) ? (int) $cred_len_unpacked[1] : 0;
			$offset += 2;
			if ( $cred_len < 1 || $cred_len > 1024 || strlen( $auth_data ) < $offset + $cred_len ) {
				return null;
			}
			$result['credential_id'] = substr( $auth_data, $offset, $cred_len );
			$offset += $cred_len;

			$cbor        = substr( $auth_data, $offset );
			$cbor_offset = 0;
			try {
				$cose_key = self::cbor_decode( $cbor, $cbor_offset, 0 );
			} catch ( Exception $e ) {
				return null;
			}
			if ( ! is_array( $cose_key ) ) {
				return null;
			}
			$result['cose_key'] = $cose_key;
		}

		return $result;
	}

	/**
	 * Convert a COSE public key to a PEM public key usable by OpenSSL.
	 *
	 * Supports EC2 P-256 and RSA keys.
	 *
	 * @param array $cose_key Decoded COSE key map.
	 * @return string|null PEM string or null when unsupported/invalid.
	 */
	public static function cose_key_to_pem( $cose_key ) {
		if ( ! is_array( $cose_key ) ) {
			return null;
		}

		$kty = isset( $cose_key[1] ) ? $cose_key[1] : null;

		if ( 2 === $kty ) { // EC2.
			$crv = isset( $cose_key[3] ) ? $cose_key[3] : null;
			$x   = isset( $cose_key[-2] ) ? $cose_key[-2] : null;
			$y   = isset( $cose_key[-3] ) ? $cose_key[-3] : null;
			if ( 1 !== $crv ) {
				return null; // Only P-256 is supported.
			}
			if ( ! is_string( $x ) || ! is_string( $y ) || 32 !== strlen( $x ) || 32 !== strlen( $y ) ) {
				return null;
			}
			return self::ec_pem_from_point( $x, $y );
		}

		if ( 3 === $kty ) { // RSA.
			$n = isset( $cose_key[-1] ) ? $cose_key[-1] : null;
			$e = isset( $cose_key[-2] ) ? $cose_key[-2] : null;
			if ( ! is_string( $n ) || ! is_string( $e ) || '' === $n || '' === $e ) {
				return null;
			}
			return self::rsa_pem_from_key( $n, $e );
		}

		return null;
	}

	/**
	 * Build a PEM-encoded P-256 public key (SubjectPublicKeyInfo) from an EC point.
	 *
	 * @param string $x X coordinate (32 bytes).
	 * @param string $y Y coordinate (32 bytes).
	 * @return string PEM string.
	 */
	private static function ec_pem_from_point( $x, $y ) {
		$oid_ec           = "\x06\x07\x2a\x86\x48\xce\x3d\x02\x01"; // id-ecPublicKey.
		$oid_prime256v1   = "\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07"; // prime256v1.
		$alg              = "\x30" . self::der_length( strlen( $oid_ec ) + strlen( $oid_prime256v1 ) ) . $oid_ec . $oid_prime256v1;
		$point            = "\x04" . $x . $y;
		$spki             = self::der_sequence( $alg . self::der_bit_string( $point ) );
		return self::pem_from_der( $spki );
	}

	/**
	 * Build a PEM-encoded RSA public key (SubjectPublicKeyInfo) from modulus and exponent.
	 *
	 * @param string $n Modulus.
	 * @param string $e Exponent.
	 * @return string PEM string.
	 */
	private static function rsa_pem_from_key( $n, $e ) {
		$oid_rsa          = "\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01"; // rsaEncryption.
		$alg              = self::der_sequence( $oid_rsa . "\x05\x00" );
		$rsa_public_key   = self::der_sequence( self::der_integer( $n ) . self::der_integer( $e ) );
		$spki             = self::der_sequence( $alg . self::der_bit_string( $rsa_public_key ) );
		return self::pem_from_der( $spki );
	}

	/**
	 * Encode a DER body as a PEM "PUBLIC KEY" block.
	 *
	 * @param string $der DER bytes.
	 * @return string PEM string.
	 */
	private static function pem_from_der( $der ) {
		return "-----BEGIN PUBLIC KEY-----\n" . chunk_split( base64_encode( $der ), 64, "\n" ) . "-----END PUBLIC KEY-----";
	}

	/**
	 * DER length prefix.
	 *
	 * @param int $len Content length.
	 * @return string Length bytes.
	 */
	private static function der_length( $len ) {
		if ( $len < 0x80 ) {
			return chr( $len );
		}
		if ( $len <= 0xFF ) {
			return chr( 0x81 ) . chr( $len );
		}
		if ( $len <= 0xFFFF ) {
			return chr( 0x82 ) . pack( 'n', $len );
		}
		return chr( 0x83 ) . pack( 'N', $len );
	}

	/**
	 * DER SEQUENCE wrapper.
	 *
	 * @param string $contents Inner DER bytes.
	 * @return string DER sequence.
	 */
	private static function der_sequence( $contents ) {
		return "\x30" . self::der_length( strlen( $contents ) ) . $contents;
	}

	/**
	 * DER INTEGER wrapper.
	 *
	 * @param string $bytes Big-endian integer bytes.
	 * @return string DER integer.
	 */
	private static function der_integer( $bytes ) {
		if ( '' === $bytes ) {
			$bytes = "\x00";
		}
		if ( ord( $bytes[0] ) & 0x80 ) {
			$bytes = "\x00" . $bytes;
		}
		return "\x02" . self::der_length( strlen( $bytes ) ) . $bytes;
	}

	/**
	 * DER BIT STRING wrapper.
	 *
	 * @param string $bytes Content bytes.
	 * @return string DER bit string.
	 */
	private static function der_bit_string( $bytes ) {
		return "\x03" . self::der_length( strlen( $bytes ) + 1 ) . "\x00" . $bytes;
	}

	/**
	 * Convert a raw ECDSA signature (r || s, 64 bytes) to DER.
	 *
	 * @param string $signature Raw 64 byte signature.
	 * @return string|null DER encoded signature or null on invalid input.
	 */
	public static function ecdsa_raw_to_der( $signature ) {
		if ( ! is_string( $signature ) || 64 !== strlen( $signature ) ) {
			return null;
		}

		$r = ltrim( substr( $signature, 0, 32 ), "\x00" );
		$s = ltrim( substr( $signature, 32, 32 ), "\x00" );

		if ( '' === $r || '' === $s ) {
			return null;
		}

		$der_r = self::der_integer( $r );
		$der_s = self::der_integer( $s );

		return "\x30" . self::der_length( strlen( $der_r ) + strlen( $der_s ) ) . $der_r . $der_s;
	}

	/**
	 * Parse a WebAuthn client data JSON blob.
	 *
	 * @param string $json Raw clientDataJSON bytes.
	 * @return array|null Decoded structure or null when invalid.
	 */
	public static function parse_client_data( $json ) {
		if ( ! is_string( $json ) || '' === $json ) {
			return null;
		}
		$decoded = json_decode( $json, true );
		if ( ! is_array( $decoded ) ) {
			return null;
		}
		return $decoded;
	}

	/**
	 * Verify a WebAuthn registration (attestation).
	 *
	 * @param string $client_data_json      Raw clientDataJSON bytes.
	 * @param string $attestation_object    Raw attestationObject bytes.
	 * @param string $challenge             Expected challenge (base64url, as sent to the client).
	 * @param string $rp_id                 Relying Party ID (host).
	 * @param string $origin                Expected origin (scheme://host[:port]).
	 * @return array|WP_Error Parsed auth data on success, WP_Error on failure.
	 */
	public static function verify_registration( $client_data_json, $attestation_object, $challenge, $rp_id, $origin ) {
		$client_data = self::parse_client_data( $client_data_json );
		if ( null === $client_data ) {
			return new WP_Error( 'zacls_invalid_client_data', esc_html__( 'Invalid WebAuthn client data.', 'za-creative-login-shield' ) );
		}

		if ( ! isset( $client_data['type'] ) || 'webauthn.create' !== $client_data['type'] ) {
			return new WP_Error( 'zacls_bad_client_type', esc_html__( 'Invalid WebAuthn registration type.', 'za-creative-login-shield' ) );
		}

		if ( ! isset( $client_data['challenge'] ) || ! self::challenge_matches( $client_data['challenge'], $challenge ) ) {
			return new WP_Error( 'zacls_challenge_mismatch', esc_html__( 'WebAuthn challenge mismatch.', 'za-creative-login-shield' ) );
		}

		if ( ! isset( $client_data['origin'] ) || ! self::origin_matches( $client_data['origin'], $origin ) ) {
			return new WP_Error( 'zacls_bad_origin', esc_html__( 'WebAuthn origin mismatch.', 'za-creative-login-shield' ) );
		}

		$offset     = 0;
		$attestation = null;
		try {
			$decoded = self::cbor_decode( $attestation_object, $offset, 0 );
		} catch ( Exception $e ) {
			return new WP_Error( 'zacls_bad_attestation', esc_html__( 'Invalid WebAuthn attestation object.', 'za-creative-login-shield' ) );
		}
		if ( ! is_array( $decoded ) ) {
			return new WP_Error( 'zacls_bad_attestation', esc_html__( 'Invalid WebAuthn attestation object.', 'za-creative-login-shield' ) );
		}

		$attestation = array(
			'fmt'      => isset( $decoded['fmt'] ) ? $decoded['fmt'] : null,
			'authData' => isset( $decoded['authData'] ) ? $decoded['authData'] : null,
		);

		if ( 'none' !== $attestation['fmt'] ) {
			return new WP_Error( 'zacls_unsupported_attestation', esc_html__( 'Only "none" attestation is supported.', 'za-creative-login-shield' ) );
		}
		if ( ! is_string( $attestation['authData'] ) ) {
			return new WP_Error( 'zacls_bad_attestation', esc_html__( 'Invalid WebAuthn attestation data.', 'za-creative-login-shield' ) );
		}

		$auth_data = self::parse_auth_data( $attestation['authData'] );
		if ( null === $auth_data ) {
			return new WP_Error( 'zacls_bad_auth_data', esc_html__( 'Invalid WebAuthn authenticator data.', 'za-creative-login-shield' ) );
		}

		if ( ! hash_equals( hash( 'sha256', $rp_id, true ), $auth_data['rp_id_hash'] ) ) {
			return new WP_Error( 'zacls_rp_id_mismatch', esc_html__( 'WebAuthn relying party mismatch.', 'za-creative-login-shield' ) );
		}

		if ( ! ( $auth_data['flags'] & 0x01 ) ) {
			return new WP_Error( 'zacls_no_user_presence', esc_html__( 'WebAuthn user presence check failed.', 'za-creative-login-shield' ) );
		}

		if ( '' === $auth_data['credential_id'] || null === $auth_data['cose_key'] ) {
			return new WP_Error( 'zacls_missing_credential', esc_html__( 'WebAuthn credential data missing.', 'za-creative-login-shield' ) );
		}

		return $auth_data;
	}

	/**
	 * Verify a WebAuthn assertion (authentication).
	 *
	 * @param string $client_data_json    Raw clientDataJSON bytes.
	 * @param string $authenticator_data  Raw authenticatorData bytes.
	 * @param string $signature           Raw signature bytes.
	 * @param string $challenge           Expected challenge (base64url, as sent to the client).
	 * @param string $rp_id               Relying Party ID (host).
	 * @param string $origin              Expected origin.
	 * @param string $public_key_pem      PEM encoded public key for the credential.
	 * @param int    $stored_sign_count   Sign count stored for the credential.
	 * @return array|WP_Error Parsed auth data on success, WP_Error on failure.
	 */
	public static function verify_assertion( $client_data_json, $authenticator_data, $signature, $challenge, $rp_id, $origin, $public_key_pem, $stored_sign_count ) {
		$client_data = self::parse_client_data( $client_data_json );
		if ( null === $client_data ) {
			return new WP_Error( 'zacls_invalid_client_data', esc_html__( 'Invalid WebAuthn client data.', 'za-creative-login-shield' ) );
		}

		if ( ! isset( $client_data['type'] ) || 'webauthn.get' !== $client_data['type'] ) {
			return new WP_Error( 'zacls_bad_client_type', esc_html__( 'Invalid WebAuthn authentication type.', 'za-creative-login-shield' ) );
		}

		if ( ! isset( $client_data['challenge'] ) || ! self::challenge_matches( $client_data['challenge'], $challenge ) ) {
			return new WP_Error( 'zacls_challenge_mismatch', esc_html__( 'WebAuthn challenge mismatch.', 'za-creative-login-shield' ) );
		}

		if ( ! isset( $client_data['origin'] ) || ! self::origin_matches( $client_data['origin'], $origin ) ) {
			return new WP_Error( 'zacls_bad_origin', esc_html__( 'WebAuthn origin mismatch.', 'za-creative-login-shield' ) );
		}

		$auth_data = self::parse_auth_data( $authenticator_data );
		if ( null === $auth_data ) {
			return new WP_Error( 'zacls_bad_auth_data', esc_html__( 'Invalid WebAuthn authenticator data.', 'za-creative-login-shield' ) );
		}

		if ( ! hash_equals( hash( 'sha256', $rp_id, true ), $auth_data['rp_id_hash'] ) ) {
			return new WP_Error( 'zacls_rp_id_mismatch', esc_html__( 'WebAuthn relying party mismatch.', 'za-creative-login-shield' ) );
		}

		if ( ! ( $auth_data['flags'] & 0x01 ) ) {
			return new WP_Error( 'zacls_no_user_presence', esc_html__( 'WebAuthn user presence check failed.', 'za-creative-login-shield' ) );
		}

		$new_sign_count = (int) $auth_data['sign_count'];
		if ( $stored_sign_count > 0 && $new_sign_count <= $stored_sign_count ) {
			return new WP_Error( 'zacls_sign_counter_replay', esc_html__( 'WebAuthn sign counter replay detected.', 'za-creative-login-shield' ) );
		}

		$client_data_hash = hash( 'sha256', $client_data_json, true );
		$data_to_sign     = $authenticator_data . $client_data_hash;

		if ( 64 === strlen( $signature ) ) {
			$der_signature = self::ecdsa_raw_to_der( $signature );
			if ( null === $der_signature ) {
				return new WP_Error( 'zacls_bad_signature', esc_html__( 'Invalid WebAuthn signature.', 'za-creative-login-shield' ) );
			}
			$signature = $der_signature;
		}

		$verify_result = openssl_verify( $data_to_sign, $signature, $public_key_pem, OPENSSL_ALGO_SHA256 );
		if ( 1 !== $verify_result ) {
			return new WP_Error( 'zacls_signature_invalid', esc_html__( 'WebAuthn signature verification failed.', 'za-creative-login-shield' ) );
		}

		return $auth_data;
	}

	/**
	 * Validate the expected origin against a client-provided origin.
	 *
	 * Supports both exact equality and subdomain-style rpId matching for
	 * WebAuthn rpId semantics where a narrower origin is not required.
	 *
	 * @param string $provided Origin from client data.
	 * @param string $expected Expected origin.
	 * @return bool Whether the origin is acceptable.
	 */
	private static function origin_matches( $provided, $expected ) {
		if ( ! is_string( $provided ) || ! is_string( $expected ) ) {
			return false;
		}
		if ( hash_equals( $expected, $provided ) ) {
			return true;
		}

		// Accept explicit "port:null" normalized forms some browsers emit.
		$normalized_expected = preg_replace( '/:null$/', '', $expected );
		if ( is_string( $normalized_expected ) && hash_equals( $normalized_expected, $provided ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Constant-time challenge comparison.
	 *
	 * @param string $provided Challenge from client data.
	 * @param string $expected Expected challenge.
	 * @return bool Whether the challenge matches.
	 */
	private static function challenge_matches( $provided, $expected ) {
		if ( ! is_string( $provided ) || ! is_string( $expected ) ) {
			return false;
		}
		return hash_equals( $expected, $provided );
	}
}