| 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 |
|