clock = is_callable($clock) ? $clock : 'time'; $this->state_loader = is_callable($state_loader) ? $state_loader : array('Akkedli_Connector_State', 'load'); $this->secret_decoder = is_callable($secret_decoder) ? $secret_decoder : array('Akkedli_V2_Crypto', 'base64url_decode'); } public function authenticate(WP_REST_Request $request) { $path = $this->expected_path(); if (PHP_INT_SIZE < 8 || !$this->has_exact_path($request, $path)) { return $this->invalid_request(); } $content_type = $this->single_header($request, 'Content-Type'); $credential_id = $this->single_header($request, 'X-Akkedli-Connector-Id'); $timestamp = $this->single_header($request, 'X-Akkedli-Timestamp'); $request_id = $this->single_header($request, 'X-Akkedli-Request-Id'); $idempotency_key = $this->single_header($request, 'X-Akkedli-Idempotency-Key'); $signature = $this->single_header($request, 'X-Akkedli-Signature'); if ('application/json' !== $content_type) { return $this->invalid_request(); } if (!is_string($credential_id) || !preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/D', $credential_id) || !is_string($timestamp) || !preg_match('/^[1-9]\d{9,10}$/D', $timestamp) || !is_string($request_id) || !preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/D', $request_id) || !is_string($idempotency_key) || !preg_match('/^vr1\.[A-Za-z0-9_-]{43}$/D', $idempotency_key) || !is_string($signature) || !preg_match('/^v1=([A-Za-z0-9_-]{43})$/D', $signature, $signature_match)) { return $this->authentication_failed(); } try { $idempotency_binary = Akkedli_V2_Crypto::base64url_decode(substr($idempotency_key, 4)); } catch (Throwable $error) { unset($error); return $this->authentication_failed(); } if (32 !== strlen($idempotency_binary)) { unset($idempotency_binary); return $this->authentication_failed(); } unset($idempotency_binary); $now = call_user_func($this->clock); $timestamp_value = (int) $timestamp; if (!is_int($now) || $now < 1 || (string) $timestamp_value !== $timestamp || abs($now - $timestamp_value) > self::SKEW_SECONDS) { return $this->authentication_failed(); } try { $state = call_user_func($this->state_loader); if (!is_array($state) || !isset($state['mode'], $state['protocol_version'], $state['credential_id'], $state['secret']) || 'v2' !== $state['mode'] || 1 !== $state['protocol_version'] || !hash_equals($state['credential_id'], $credential_id)) { return $this->authentication_failed(); } $secret = call_user_func($this->secret_decoder, $state['secret']); $provided = Akkedli_V2_Crypto::base64url_decode($signature_match[1]); } catch (Throwable $error) { unset($error); return $this->authentication_failed(); } if (!is_string($secret) || !is_string($provided) || 32 !== strlen($secret) || 32 !== strlen($provided)) { unset($secret, $provided); return $this->authentication_failed(); } $raw_body = $request->get_body(); if (!is_string($raw_body) || '' === $raw_body || strlen($raw_body) > self::BODY_LIMIT) { unset($secret, $provided); return $this->invalid_request(); } $body_hash = hash('sha256', $raw_body); $canonical = implode("\n", array( 'AKKEDLI-WC-CALLBACK-V1', 'POST', $path, $timestamp, $request_id, $idempotency_key, $body_hash, )); $expected = hash_hmac('sha256', $canonical, $secret, true); if (!hash_equals($expected, $provided)) { unset($secret, $provided, $expected, $canonical, $body_hash, $raw_body); return $this->authentication_failed(); } unset($provided, $expected, $canonical); $payload = $this->parse_body($raw_body); unset($raw_body); if (!is_array($payload)) { unset($secret, $body_hash); return $this->invalid_request(); } $claim_identity = implode("\n", array( 'AKKEDLI-WC-CALLBACK-CLAIM-V1', 'POST', $path, 'protocol:1', 'blog:' . (string) get_current_blog_id(), 'credential:' . $credential_id, 'idempotency:' . $idempotency_key, 'body-sha256:' . $body_hash, 'order:' . $payload['order_id'], 'result:' . $payload['verification_result'], )); $request_fingerprint = Akkedli_V2_Crypto::base64url_encode(hash_hmac( 'sha256', "request\n" . $timestamp . "\n" . $request_id . "\n" . $claim_identity, $secret, true )); $idempotency_fingerprint = Akkedli_V2_Crypto::base64url_encode(hash_hmac( 'sha256', "idempotency\n" . $claim_identity, $secret, true )); unset($body_hash, $claim_identity, $state); return array( 'secret' => $secret, 'request_id' => $request_id, 'idempotency_key' => $idempotency_key, 'request_fingerprint' => $request_fingerprint, 'idempotency_fingerprint' => $idempotency_fingerprint, 'order_id' => $payload['order_id'], 'order_id_int' => (int) $payload['order_id'], 'verification_result' => $payload['verification_result'], ); } private function has_exact_path(WP_REST_Request $request, $expected) { if (!method_exists($request, 'get_method') || 'POST' !== $request->get_method() || !method_exists($request, 'get_route') || self::ROUTE !== $request->get_route()) { return false; } $actual = isset($_SERVER['REQUEST_URI']) && is_string($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : ''; return is_string($expected) && '' !== $expected && hash_equals($expected, $actual); } private function expected_path() { $url = rest_url(ltrim(self::ROUTE, '/')); $parts = is_string($url) ? wp_parse_url($url) : false; if (!is_array($parts) || !isset($parts['path']) || !is_string($parts['path']) || isset($parts['query']) || isset($parts['fragment'])) { return false; } $path = $parts['path']; if ('' === $path || '/' !== $path[0] || '/' === substr($path, -1) || false !== strpos($path, '//') || false !== strpos($path, '\\') || preg_match('/%(?:2f|5c|2e|25)/i', $path)) { return false; } return $path; } private function single_header(WP_REST_Request $request, $name) { $headers = $request->get_headers(); $key = strtolower(str_replace('-', '_', $name)); if (!is_array($headers) || !isset($headers[$key]) || !is_array($headers[$key]) || 1 !== count($headers[$key]) || !is_string($headers[$key][0])) { return false; } $value = $headers[$key][0]; return '' !== $value && !preg_match('/[\x00-\x20\x7f,]/', $value) ? $value : false; } private function parse_body($body) { if (1 !== preg_match('//u', $body)) { return false; } $decoded = json_decode($body, true); if (!is_array($decoded) || JSON_ERROR_NONE !== json_last_error() || !$this->has_exact_keys($decoded, array('order_id', 'verification_result')) || !is_string($decoded['order_id']) || !is_string($decoded['verification_result']) || !in_array($decoded['verification_result'], array('verified', 'cancelled'), true) || !preg_match('/^[1-9]\d{0,15}$/D', $decoded['order_id'])) { return false; } preg_match_all('/"((?:[^"\\\\]|\\\\.)*)"\s*:/', $body, $matches); if (2 !== count($matches[1])) { return false; } $keys = array(); foreach ($matches[1] as $encoded_key) { $key = json_decode('"' . $encoded_key . '"'); if (!is_string($key)) { return false; } $keys[] = $key; } sort($keys); if ($keys !== array('order_id', 'verification_result')) { return false; } $order_id = $decoded['order_id']; if ((16 === strlen($order_id) && strcmp($order_id, self::MAX_ORDER_ID) > 0) || (string) ((int) $order_id) !== $order_id) { return false; } return $decoded; } private function has_exact_keys(array $value, array $keys) { $actual = array_keys($value); sort($actual); sort($keys); return $actual === $keys; } private function invalid_request() { return new WP_Error('akkedli_v2_invalid_request', 'Invalid Connector request.', array('status' => 400)); } private function authentication_failed() { return new WP_Error('akkedli_v2_authentication_failed', 'Connector authentication failed.', array('status' => 401)); } }