PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.1
16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 13.9.2 14.0.1 14.1.1 All 503 releases
jetpack / vendor / wp-php-toolkit / reprint-server / src / class-hmac-server.php

class-hmac-server.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.1, at vendor/wp-php-toolkit/reprint-server/src/class-hmac-server.php

266 lines 9.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 }
266