← All changes
|
vendor/wp-php-toolkit/reprint-server/src/class-hmac-server.php
+265
-0
16.2-beta
→
16.3
View file →
| @@ -1,0 +1,265 @@ | ||
| 1 | +<?php | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * HMAC verifier for Reprint API requests. | |
| 5 | + * | |
| 6 | + * This class verifies requests signed by Site_Export_HMAC_Client. It validates | |
| 7 | + * the required X-Auth-* headers and checks request freshness before hashing a | |
| 8 | + * received body. | |
| 9 | + */ | |
| 10 | +final class Site_Export_HMAC_Server { | |
| 11 | + | |
| 12 | + /** | |
| 13 | + * Value of the X-Auth-Content-Hash header when the request body is | |
| 14 | + * deliberately not signed: this literal string stands where a body hash | |
| 15 | + * would otherwise be. Must match Site_Export_HMAC_Client::UNSIGNED_PAYLOAD. | |
| 16 | + */ | |
| 17 | + public const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD'; | |
| 18 | + | |
| 19 | + /** @var string */ | |
| 20 | + private $secret; | |
| 21 | + | |
| 22 | + /** @var int */ | |
| 23 | + private $timestamp_tolerance; | |
| 24 | + | |
| 25 | + public function __construct(string $secret, int $timestamp_tolerance = 300) { | |
| 26 | + $this->secret = $secret; | |
| 27 | + $this->timestamp_tolerance = $timestamp_tolerance; | |
| 28 | + } | |
| 29 | + | |
| 30 | + /** | |
| 31 | + * Verify one body-signed pull request using explicit inputs. | |
| 32 | + * | |
| 33 | + * Returns null on success, or an error string on failure. Push uploads use | |
| 34 | + * envelope authentication instead of whole-body HMAC verification. | |
| 35 | + * | |
| 36 | + * When $files is non-empty, the content hash is computed from uploaded file | |
| 37 | + * contents rather than $body so multipart uploads verify consistently. | |
| 38 | + */ | |
| 39 | + public function verify(array $headers = [], ?string $body = null, array $files = [], ?float $now = null): ?string { | |
| 40 | + $auth = $this->collect_auth_headers($headers); | |
| 41 | + $auth_error = $this->verify_auth_headers($auth, $now); | |
| 42 | + if ($auth_error !== null) { | |
| 43 | + return $auth_error; | |
| 44 | + } | |
| 45 | + | |
| 46 | + try { | |
| 47 | + $actual_content_hash = $this->compute_received_content_hash($body, $files); | |
| 48 | + } catch (RuntimeException $e) { | |
| 49 | + return $e->getMessage(); | |
| 50 | + } | |
| 51 | + | |
| 52 | + if (!hash_equals($auth['content_hash'], $actual_content_hash)) { | |
| 53 | + return 'Content hash mismatch: body was modified in transit'; | |
| 54 | + } | |
| 55 | + | |
| 56 | + return null; | |
| 57 | + } | |
| 58 | + | |
| 59 | + /** | |
| 60 | + * Verify a request whose body is deliberately not signed. | |
| 61 | + * | |
| 62 | + * Instead of a body hash, the signature covers exactly four values: | |
| 63 | + * the nonce, the timestamp, the HTTP method, and the request target | |
| 64 | + * (the "path?query" part of the URL). A request body of any size can then | |
| 65 | + * stream through without either side hashing it, and a captured set of | |
| 66 | + * auth headers still cannot be reused for a different endpoint or | |
| 67 | + * method. Protecting the body from tampering is TLS's job. | |
| 68 | + * | |
| 69 | + * The X-Auth-Content-Hash header must be the literal string | |
| 70 | + * UNSIGNED-PAYLOAD. Because of that, headers signed for this check can | |
| 71 | + * never pass the body-signed checks and vice versa — the two signatures | |
| 72 | + * are computed over strings that can never be equal. Each route decides | |
| 73 | + * which check it calls, so a client cannot make a pull endpoint accept | |
| 74 | + * this body-less check. | |
| 75 | + * | |
| 76 | + * @param string $request_target The "path?query" form of the request URL. | |
| 77 | + */ | |
| 78 | + public function verify_envelope(array $headers, string $method, string $request_target, ?float $now = null): ?string { | |
| 79 | + $auth = $this->collect_auth_headers($headers); | |
| 80 | + if ($auth['content_hash'] !== self::UNSIGNED_PAYLOAD) { | |
| 81 | + return 'Envelope verification requires the literal UNSIGNED-PAYLOAD content hash'; | |
| 82 | + } | |
| 83 | + | |
| 84 | + $freshness_error = $this->verify_freshness($auth, $now); | |
| 85 | + if ($freshness_error !== null) { | |
| 86 | + return $freshness_error; | |
| 87 | + } | |
| 88 | + | |
| 89 | + $message = $auth['nonce'] . $auth['timestamp'] . self::UNSIGNED_PAYLOAD . "\n" . strtoupper($method) . "\n" . $request_target; | |
| 90 | + $expected_signature = hash_hmac('sha256', $message, $this->secret); | |
| 91 | + if (!hash_equals($expected_signature, $auth['signature'])) { | |
| 92 | + return 'HMAC signature verification failed'; | |
| 93 | + } | |
| 94 | + | |
| 95 | + return null; | |
| 96 | + } | |
| 97 | + | |
| 98 | + /** | |
| 99 | + * Verify the current PHP request using superglobals. | |
| 100 | + * | |
| 101 | + * Returns null on success, or an error string on failure. Pull endpoints | |
| 102 | + * use body signatures; push uploads use verify_envelope(). | |
| 103 | + */ | |
| 104 | + public function verify_globals(?float $now = null): ?string { | |
| 105 | + $body = file_get_contents('php://input'); | |
| 106 | + if ($body === false) { | |
| 107 | + $body = ''; | |
| 108 | + } | |
| 109 | + | |
| 110 | + return $this->verify($this->collect_global_headers(), $body, $_FILES, $now); | |
| 111 | + } | |
| 112 | + | |
| 113 | + private function collect_auth_headers(array $headers): array { | |
| 114 | + return [ | |
| 115 | + 'signature' => $this->get_header($headers, 'X-Auth-Signature'), | |
| 116 | + 'nonce' => $this->get_header($headers, 'X-Auth-Nonce'), | |
| 117 | + 'timestamp' => $this->get_header($headers, 'X-Auth-Timestamp'), | |
| 118 | + 'content_hash' => $this->get_header($headers, 'X-Auth-Content-Hash'), | |
| 119 | + ]; | |
| 120 | + } | |
| 121 | + | |
| 122 | + private function verify_auth_headers(array $auth, ?float $now = null): ?string { | |
| 123 | + $freshness_error = $this->verify_freshness($auth, $now); | |
| 124 | + if ($freshness_error !== null) { | |
| 125 | + return $freshness_error; | |
| 126 | + } | |
| 127 | + | |
| 128 | + $expected_signature = hash_hmac('sha256', $auth['nonce'] . $auth['timestamp'] . $auth['content_hash'], $this->secret); | |
| 129 | + if (!hash_equals($expected_signature, $auth['signature'])) { | |
| 130 | + return 'HMAC signature verification failed'; | |
| 131 | + } | |
| 132 | + | |
| 133 | + return null; | |
| 134 | + } | |
| 135 | + | |
| 136 | + /** | |
| 137 | + * Checks header presence, timestamp tolerance, and nonce length — | |
| 138 | + * everything except the signature. Body-signed and envelope-signed | |
| 139 | + * requests compute their signatures over different strings, so each | |
| 140 | + * caller does its own signature check after this passes. | |
| 141 | + */ | |
| 142 | + private function verify_freshness(array $auth, ?float $now = null): ?string { | |
| 143 | + $signature = $auth['signature']; | |
| 144 | + $nonce = $auth['nonce']; | |
| 145 | + $timestamp = $auth['timestamp']; | |
| 146 | + $signed_content_hash = $auth['content_hash']; | |
| 147 | + if ($signature === null || $signature === '') { | |
| 148 | + return 'Missing X-Auth-Signature header'; | |
| 149 | + } | |
| 150 | + if ($nonce === null || $nonce === '') { | |
| 151 | + return 'Missing X-Auth-Nonce header'; | |
| 152 | + } | |
| 153 | + if ($timestamp === null || $timestamp === '') { | |
| 154 | + return 'Missing X-Auth-Timestamp header'; | |
| 155 | + } | |
| 156 | + if ($signed_content_hash === null || $signed_content_hash === '') { | |
| 157 | + return 'Missing X-Auth-Content-Hash header'; | |
| 158 | + } | |
| 159 | + | |
| 160 | + if (!is_numeric($timestamp)) { | |
| 161 | + return 'Invalid timestamp format'; | |
| 162 | + } | |
| 163 | + | |
| 164 | + $request_time = (float) $timestamp; | |
| 165 | + $current_time = $now ?? microtime(true); | |
| 166 | + $time_diff = abs($current_time - $request_time); | |
| 167 | + | |
| 168 | + if ($time_diff > $this->timestamp_tolerance) { | |
| 169 | + return sprintf( | |
| 170 | + 'Request timestamp expired. Difference: %.2f seconds, max allowed: %d seconds', | |
| 171 | + $time_diff, | |
| 172 | + $this->timestamp_tolerance | |
| 173 | + ); | |
| 174 | + } | |
| 175 | + | |
| 176 | + if (strlen($nonce) < 16) { | |
| 177 | + return 'Nonce must be at least 16 characters'; | |
| 178 | + } | |
| 179 | + | |
| 180 | + return null; | |
| 181 | + } | |
| 182 | + | |
| 183 | + private function collect_global_headers(): array { | |
| 184 | + $headers = []; | |
| 185 | + | |
| 186 | + if (function_exists('getallheaders')) { | |
| 187 | + $all_headers = getallheaders(); | |
| 188 | + if (is_array($all_headers)) { | |
| 189 | + $headers = $all_headers; | |
| 190 | + } | |
| 191 | + } | |
| 192 | + | |
| 193 | + foreach ($_SERVER as $key => $value) { | |
| 194 | + if (strpos($key, 'HTTP_') !== 0 || !is_string($value)) { | |
| 195 | + continue; | |
| 196 | + } | |
| 197 | + | |
| 198 | + $headers[$key] = $value; | |
| 199 | + } | |
| 200 | + | |
| 201 | + return $headers; | |
| 202 | + } | |
| 203 | + | |
| 204 | + private function get_header(array $headers, string $name): ?string { | |
| 205 | + foreach ($headers as $key => $value) { | |
| 206 | + if (!is_string($value)) { | |
| 207 | + continue; | |
| 208 | + } | |
| 209 | + | |
| 210 | + if (strcasecmp($key, $name) === 0) { | |
| 211 | + return $value; | |
| 212 | + } | |
| 213 | + | |
| 214 | + if (strcasecmp($key, 'HTTP_' . strtoupper(str_replace('-', '_', $name))) === 0) { | |
| 215 | + return $value; | |
| 216 | + } | |
| 217 | + } | |
| 218 | + | |
| 219 | + return null; | |
| 220 | + } | |
| 221 | + | |
| 222 | + private function compute_received_content_hash(?string $body, array $files): string { | |
| 223 | + if (empty($files)) { | |
| 224 | + return hash('sha256', $body ?? ''); | |
| 225 | + } | |
| 226 | + | |
| 227 | + $context = hash_init('sha256'); | |
| 228 | + $this->append_file_hashes($context, $files); | |
| 229 | + return hash_final($context); | |
| 230 | + } | |
| 231 | + | |
| 232 | + /** | |
| 233 | + * Walk a PHP $_FILES-style structure in a deterministic order. | |
| 234 | + */ | |
| 235 | + private function append_file_hashes($context, array $files): void { | |
| 236 | + ksort($files); | |
| 237 | + | |
| 238 | + foreach ($files as $file_info) { | |
| 239 | + if (!is_array($file_info)) { | |
| 240 | + continue; | |
| 241 | + } | |
| 242 | + | |
| 243 | + $tmp_name = $file_info['tmp_name'] ?? null; | |
| 244 | + $this->append_tmp_name_hash($context, $tmp_name); | |
| 245 | + } | |
| 246 | + } | |
| 247 | + | |
| 248 | + private function append_tmp_name_hash($context, $tmp_name): void { | |
| 249 | + if (is_array($tmp_name)) { | |
| 250 | + ksort($tmp_name); | |
| 251 | + foreach ($tmp_name as $nested_tmp_name) { | |
| 252 | + $this->append_tmp_name_hash($context, $nested_tmp_name); | |
| 253 | + } | |
| 254 | + return; | |
| 255 | + } | |
| 256 | + | |
| 257 | + if (!is_string($tmp_name) || $tmp_name === '' || !is_readable($tmp_name)) { | |
| 258 | + return; | |
| 259 | + } | |
| 260 | + | |
| 261 | + if (!@hash_update_file($context, $tmp_name)) { | |
| 262 | + throw new RuntimeException('Cannot hash uploaded file.'); | |
| 263 | + } | |
| 264 | + } | |
| 265 | +} | |