PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 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 All 508 releases
← 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 +}