1 | <?php declare(strict_types=1); |
||||
2 | |||||
3 | namespace Jasny\HttpSignature; |
||||
4 | |||||
5 | use Improved as i; |
||||
6 | use const Improved\FUNCTION_ARGUMENT_PLACEHOLDER as __; |
||||
0 ignored issues
–
show
Bug
introduced
by
![]() |
|||||
7 | |||||
8 | use Carbon\CarbonImmutable; |
||||
9 | use Improved\IteratorPipeline\Pipeline; |
||||
10 | use Psr\Http\Message\RequestInterface as Request; |
||||
11 | use Psr\Http\Message\ResponseInterface as Response; |
||||
12 | |||||
13 | /** |
||||
14 | * Create and verify HTTP Signatures. |
||||
15 | * Only support signatures using the ED25519 algorithm. |
||||
16 | */ |
||||
17 | class HttpSignature |
||||
18 | { |
||||
19 | /** |
||||
20 | * @var int |
||||
21 | */ |
||||
22 | protected $clockSkew = 300; |
||||
23 | |||||
24 | /** |
||||
25 | * Headers required / used in message per request method. |
||||
26 | * @var array |
||||
27 | */ |
||||
28 | protected $requiredHeaders = [ |
||||
29 | 'default' => ['(request-target)', 'date'], |
||||
30 | ]; |
||||
31 | |||||
32 | /** |
||||
33 | * Supported algorithms |
||||
34 | * @var string[] |
||||
35 | */ |
||||
36 | protected $supportedAlgorithms; |
||||
37 | |||||
38 | /** |
||||
39 | * Function to sign a request. |
||||
40 | * @var callable |
||||
41 | */ |
||||
42 | protected $sign; |
||||
43 | |||||
44 | /** |
||||
45 | * Function to verify a signed request. |
||||
46 | * @var callable |
||||
47 | */ |
||||
48 | protected $verify; |
||||
49 | |||||
50 | |||||
51 | /** |
||||
52 | * Class construction. |
||||
53 | * |
||||
54 | * @param string|string[] $algorithm Supported algorithm(s). |
||||
55 | * @param callable $sign Function to sign a request. |
||||
56 | * @param callable $verify Function to verify a signed request. |
||||
57 | */ |
||||
58 | 35 | public function __construct($algorithm, callable $sign, callable $verify) |
|||
59 | { |
||||
60 | 35 | if (is_array($algorithm) && count($algorithm) === 0) { |
|||
61 | 1 | throw new \InvalidArgumentException('No supported algorithms specified'); |
|||
62 | } |
||||
63 | |||||
64 | 34 | $this->supportedAlgorithms = is_array($algorithm) ? array_values($algorithm) : [$algorithm]; |
|||
65 | |||||
66 | 34 | $this->sign = $sign; |
|||
67 | 34 | $this->verify = $verify; |
|||
68 | 34 | } |
|||
69 | |||||
70 | /** |
||||
71 | * Create a clone of the service where one of the algorithms is supported. |
||||
72 | * |
||||
73 | * @param string $algorithm |
||||
74 | * @return self |
||||
75 | * @throw \InvalidArgumentException |
||||
76 | */ |
||||
77 | 2 | public function withAlgorithm(string $algorithm) |
|||
78 | { |
||||
79 | 2 | if ($this->supportedAlgorithms === [$algorithm]) { |
|||
80 | 1 | return $this; |
|||
81 | } |
||||
82 | |||||
83 | 2 | if (!in_array($algorithm, $this->supportedAlgorithms, true)) { |
|||
84 | 1 | throw new \InvalidArgumentException('Unsupported algorithm: ' . $algorithm); |
|||
85 | } |
||||
86 | |||||
87 | 1 | $clone = clone $this; |
|||
88 | 1 | $clone->supportedAlgorithms = [$algorithm]; |
|||
89 | |||||
90 | 1 | return $clone; |
|||
91 | } |
||||
92 | |||||
93 | /** |
||||
94 | * Get supported cryptography algorithms. |
||||
95 | * |
||||
96 | * @return string[] |
||||
97 | */ |
||||
98 | 5 | public function getSupportedAlgorithms(): array |
|||
99 | { |
||||
100 | 5 | return $this->supportedAlgorithms; |
|||
101 | } |
||||
102 | |||||
103 | /** |
||||
104 | * Get service with modified max clock offset. |
||||
105 | * |
||||
106 | * @param int $clockSkew |
||||
107 | * @return static |
||||
108 | */ |
||||
109 | 1 | public function withClockSkew(int $clockSkew = 300) |
|||
110 | { |
||||
111 | 1 | if ($this->clockSkew === $clockSkew) { |
|||
112 | 1 | return $this; |
|||
113 | } |
||||
114 | |||||
115 | 1 | $clone = clone $this; |
|||
116 | 1 | $clone->clockSkew = $clockSkew; |
|||
117 | |||||
118 | 1 | return $clone; |
|||
119 | } |
||||
120 | |||||
121 | /** |
||||
122 | * Get the max clock offset. |
||||
123 | * |
||||
124 | * @return int |
||||
125 | */ |
||||
126 | 1 | public function getClockSkew(): int |
|||
127 | { |
||||
128 | 1 | return $this->clockSkew; |
|||
129 | } |
||||
130 | |||||
131 | /** |
||||
132 | * Set the required headers for the signature message. |
||||
133 | * |
||||
134 | * @param string $method HTTP Request method or 'default' |
||||
135 | * @param array $headers |
||||
136 | * @return static |
||||
137 | */ |
||||
138 | 9 | public function withRequiredHeaders(string $method, array $headers) |
|||
139 | { |
||||
140 | 9 | $method = strtolower($method); |
|||
141 | |||||
142 | 9 | $headers = Pipeline::with($headers) |
|||
143 | 9 | ->map(i\function_partial('strtolower', __)) |
|||
0 ignored issues
–
show
The function
function_partial was not found. Maybe you did not declare it correctly or list all dependencies?
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||
144 | 9 | ->values() |
|||
145 | 9 | ->toArray(); |
|||
146 | |||||
147 | 9 | if (isset($this->requiredHeaders[$method]) && $this->requiredHeaders[$method] === $headers) { |
|||
148 | 1 | return $this; |
|||
149 | } |
||||
150 | |||||
151 | 9 | $clone = clone $this; |
|||
152 | 9 | $clone->requiredHeaders[$method] = $headers; |
|||
153 | |||||
154 | 9 | return $clone; |
|||
155 | } |
||||
156 | |||||
157 | /** |
||||
158 | * Get the required headers for the signature message. |
||||
159 | * |
||||
160 | * @param string $method |
||||
161 | * @return string[] |
||||
162 | */ |
||||
163 | 19 | public function getRequiredHeaders(string $method): array |
|||
164 | { |
||||
165 | 19 | $method = strtolower($method); |
|||
166 | |||||
167 | 19 | return $this->requiredHeaders[$method] ?? $this->requiredHeaders['default']; |
|||
168 | } |
||||
169 | |||||
170 | |||||
171 | /** |
||||
172 | * Verify the signature |
||||
173 | * |
||||
174 | * @param Request $request |
||||
175 | * @return string `keyId` parameter |
||||
176 | * @throws HttpSignatureException |
||||
177 | */ |
||||
178 | 18 | public function verify(Request $request): string |
|||
179 | { |
||||
180 | 18 | $params = $this->getParams($request); |
|||
181 | 15 | $this->assertParams($params); |
|||
182 | |||||
183 | 10 | $method = $request->getMethod(); |
|||
184 | 10 | $headers = isset($params['headers']) ? explode(' ', $params['headers']) : []; |
|||
185 | 10 | $this->assertRequiredHeaders($request, $method, $headers); |
|||
186 | |||||
187 | 7 | $this->assertSignatureAge($request); |
|||
188 | |||||
189 | 6 | $message = $this->getMessage($request, $headers); |
|||
190 | 6 | $keyId = $params['keyId'] ?? ''; |
|||
191 | 6 | $signature = base64_decode($params['signature'] ?? '', true); |
|||
192 | |||||
193 | 6 | $verified = ($this->verify)($message, $signature, $keyId, $params['algorithm'] ?? 'unknown'); |
|||
194 | |||||
195 | 6 | if (!$verified) { |
|||
196 | 1 | throw new HttpSignatureException("invalid signature"); |
|||
197 | } |
||||
198 | |||||
199 | 5 | return $params['keyId']; |
|||
200 | } |
||||
201 | |||||
202 | /** |
||||
203 | * Sign a request. |
||||
204 | * |
||||
205 | * @param Request $request |
||||
206 | * @param string $keyId Public key or key reference |
||||
207 | * @param string|null $algorithm Signing algorithm, must be specified if more than one is supported. |
||||
208 | * @return Request |
||||
209 | * @throws \RuntimeException for an unsupported or unspecified algorithm |
||||
210 | */ |
||||
211 | 8 | public function sign(Request $request, string $keyId, ?string $algorithm = null): Request |
|||
212 | { |
||||
213 | 8 | $algorithm = $this->getSignAlgorithm($algorithm); |
|||
214 | |||||
215 | 6 | if (!$request->hasHeader('Date') && !$request->hasHeader('X-Date')) { |
|||
216 | 1 | $date = CarbonImmutable::now()->format(DATE_RFC1123); |
|||
217 | 1 | $request = $request->withHeader('Date', $date); |
|||
218 | } |
||||
219 | |||||
220 | 6 | $headers = $this->getSignHeaders($request); |
|||
221 | |||||
222 | $params = [ |
||||
223 | 6 | 'keyId' => $keyId, |
|||
224 | 6 | 'algorithm' => $algorithm, |
|||
225 | 6 | 'headers' => join(' ', $headers), |
|||
226 | ]; |
||||
227 | |||||
228 | 6 | $message = $this->getMessage($request, $headers); |
|||
229 | |||||
230 | 6 | $rawSignature = ($this->sign)($message, $keyId, $params['algorithm']); |
|||
231 | 6 | i\type_check($rawSignature, 'string', new \UnexpectedValueException('Expected %2$s, %1$s given')); |
|||
232 | |||||
233 | 6 | $signature = base64_encode($rawSignature); |
|||
234 | |||||
235 | 6 | $args = [$params['keyId'], $params['algorithm'], $params['headers'], $signature]; |
|||
236 | 6 | $header = sprintf('Signature keyId="%s",algorithm="%s",headers="%s",signature="%s"', ...$args); |
|||
237 | |||||
238 | 6 | return $request->withHeader('Authorization', $header); |
|||
239 | } |
||||
240 | |||||
241 | /** |
||||
242 | * Set the `WWW-Authenticate` header for each algorithm (on a 401 response). |
||||
243 | * |
||||
244 | * @param string $method |
||||
245 | * @param Response $response |
||||
246 | * @return Response |
||||
247 | */ |
||||
248 | 2 | public function setAuthenticateResponseHeader(string $method, Response $response): Response |
|||
249 | { |
||||
250 | 2 | $algorithms = $this->getSupportedAlgorithms(); |
|||
251 | 2 | $requiredHeaders = $this->getRequiredHeaders($method); |
|||
252 | |||||
253 | 2 | $header = sprintf('Signature algorithm="%%s",headers="%s"', join(' ', $requiredHeaders)); |
|||
254 | |||||
255 | 2 | foreach ($algorithms as $algorithm) { |
|||
256 | 2 | $response = $response->withHeader('WWW-Authenticate', sprintf($header, $algorithm)); |
|||
257 | } |
||||
258 | |||||
259 | 2 | return $response; |
|||
260 | } |
||||
261 | |||||
262 | /** |
||||
263 | * Extract the authorization Signature parameters |
||||
264 | * |
||||
265 | * @param Request $request |
||||
266 | * @return string[] |
||||
267 | * @throws HttpSignatureException |
||||
268 | */ |
||||
269 | 18 | protected function getParams(Request $request): array |
|||
270 | { |
||||
271 | 18 | if (!$request->hasHeader('authorization')) { |
|||
272 | 1 | throw new HttpSignatureException('missing "Authorization" header'); |
|||
273 | } |
||||
274 | |||||
275 | 17 | $auth = $request->getHeaderLine('authorization'); |
|||
276 | |||||
277 | 17 | list($method, $paramString) = explode(' ', $auth, 2) + [null, null]; |
|||
278 | |||||
279 | 17 | if (strtolower($method) !== 'signature') { |
|||
280 | 1 | throw new HttpSignatureException(sprintf('authorization scheme should be "Signature" not "%s"', $method)); |
|||
281 | } |
||||
282 | |||||
283 | 16 | if (!preg_match_all('/(\w+)\s*=\s*"([^"]++)"\s*(,|$)/', $paramString, $matches, PREG_PATTERN_ORDER)) { |
|||
284 | 1 | throw new HttpSignatureException('corrupt "Authorization" header'); |
|||
285 | } |
||||
286 | |||||
287 | 15 | return array_combine($matches[1], $matches[2]); |
|||
0 ignored issues
–
show
|
|||||
288 | } |
||||
289 | |||||
290 | /** |
||||
291 | * Assert that required headers are present |
||||
292 | * |
||||
293 | * @param Request $request |
||||
294 | * @param string $method |
||||
295 | * @param string[] $headers |
||||
296 | * @throws HttpSignatureException |
||||
297 | */ |
||||
298 | 10 | protected function assertRequiredHeaders(Request $request, string $method, array $headers): void |
|||
299 | { |
||||
300 | 10 | if (in_array('x-date', $headers, true)) { |
|||
301 | 2 | $key = array_search('x-date', $headers, true); |
|||
302 | 2 | $headers[$key] = 'date'; |
|||
303 | } |
||||
304 | |||||
305 | 10 | $requestHeaders = array_keys($request->getHeaders()); |
|||
306 | 10 | $required = array_intersect($this->getRequiredHeaders($method), $requestHeaders); |
|||
307 | |||||
308 | 10 | $missing = array_diff($required, $headers); |
|||
309 | |||||
310 | 10 | if ($missing !== []) { |
|||
311 | 3 | $err = sprintf("%s %s not part of signature", join(', ', $missing), count($missing) === 1 ? 'is' : 'are'); |
|||
312 | 3 | throw new HttpSignatureException($err); |
|||
313 | } |
||||
314 | 7 | } |
|||
315 | |||||
316 | /** |
||||
317 | * Get message that should be signed. |
||||
318 | * |
||||
319 | * @param Request $request |
||||
320 | * @param string[] $headers |
||||
321 | * @return string |
||||
322 | */ |
||||
323 | 12 | protected function getMessage(Request $request, array $headers): string |
|||
324 | { |
||||
325 | 12 | $headers = Pipeline::with($headers) |
|||
326 | 12 | ->map(i\function_partial('strtolower', __)) |
|||
0 ignored issues
–
show
The function
function_partial was not found. Maybe you did not declare it correctly or list all dependencies?
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||
327 | 12 | ->toArray(); |
|||
328 | |||||
329 | 12 | if (in_array('date', $headers, true) && $request->hasHeader('X-Date')) { |
|||
330 | 1 | $index = array_search('date', $headers, true); |
|||
331 | 1 | $headers[$index] = 'x-date'; |
|||
332 | } |
||||
333 | |||||
334 | 12 | $message = []; |
|||
335 | |||||
336 | 12 | foreach ($headers as $header) { |
|||
337 | 12 | $message[] = $header === '(request-target)' |
|||
338 | 12 | ? sprintf("%s: %s", '(request-target)', $this->getRequestTarget($request)) |
|||
339 | 11 | : sprintf("%s: %s", $header, $request->getHeaderLine($header)); |
|||
340 | } |
||||
341 | |||||
342 | 12 | return join("\n", $message); |
|||
343 | } |
||||
344 | |||||
345 | /** |
||||
346 | * Build a request line. |
||||
347 | * |
||||
348 | * @param Request $request |
||||
349 | * @return string |
||||
350 | */ |
||||
351 | 12 | protected function getRequestTarget(Request $request): string |
|||
352 | { |
||||
353 | 12 | $method = strtolower($request->getMethod()); |
|||
354 | 12 | $uri = (string)$request->getUri()->withScheme('')->withHost('')->withPort(null)->withUserInfo(''); |
|||
355 | |||||
356 | 12 | return $method . ' ' . $uri; |
|||
357 | } |
||||
358 | |||||
359 | /** |
||||
360 | * Assert all required parameters are available. |
||||
361 | * |
||||
362 | * @param string[] $params |
||||
363 | * @throws HttpSignatureException |
||||
364 | */ |
||||
365 | 15 | protected function assertParams(array $params): void |
|||
366 | { |
||||
367 | 15 | $required = ['keyId', 'algorithm', 'headers', 'signature']; |
|||
368 | |||||
369 | 15 | foreach ($required as $param) { |
|||
370 | 15 | if (!isset($params[$param])) { |
|||
371 | 4 | throw new HttpSignatureException("{$param} not specified in Authorization header"); |
|||
372 | } |
||||
373 | } |
||||
374 | |||||
375 | 11 | if (!in_array($params['algorithm'], $this->supportedAlgorithms, true)) { |
|||
376 | 1 | throw new HttpSignatureException(sprintf( |
|||
377 | 1 | 'signed with unsupported algorithm: %s', |
|||
378 | 1 | $params['algorithm'] |
|||
379 | )); |
||||
380 | } |
||||
381 | 10 | } |
|||
382 | |||||
383 | /** |
||||
384 | * Asset that the signature is not to old |
||||
385 | * |
||||
386 | * @param Request $request |
||||
387 | * @throws HttpSignatureException |
||||
388 | */ |
||||
389 | 7 | protected function assertSignatureAge(Request $request): void |
|||
390 | { |
||||
391 | $dateString = |
||||
392 | 7 | ($request->hasHeader('x-date') ? $request->getHeaderLine('x-date') : null) ?? |
|||
393 | 7 | ($request->hasHeader('date') ? $request->getHeaderLine('date') : null); |
|||
394 | |||||
395 | 7 | if ($dateString === null) { |
|||
396 | 1 | return; // Normally 'Date' should be a required header, so we shouldn't event get to this point. |
|||
397 | } |
||||
398 | |||||
399 | 6 | $date = CarbonImmutable::instance(new \DateTime($dateString)); |
|||
400 | |||||
401 | 6 | if (abs(CarbonImmutable::now()->diffInSeconds($date)) > $this->clockSkew) { |
|||
402 | 1 | throw new HttpSignatureException("signature to old or system clocks out of sync"); |
|||
403 | } |
||||
404 | 5 | } |
|||
405 | |||||
406 | /** |
||||
407 | * Get the headers that should be part of the message used to create the signature. |
||||
408 | * |
||||
409 | * @param Request $request |
||||
410 | * @return string[] |
||||
411 | */ |
||||
412 | 6 | protected function getSignHeaders(Request $request): array |
|||
413 | { |
||||
414 | 6 | $headers = $this->getRequiredHeaders($request->getMethod()); |
|||
415 | |||||
416 | 6 | return Pipeline::with($headers) |
|||
417 | ->filter(static function (string $header) use ($request) { |
||||
0 ignored issues
–
show
|
|||||
418 | 6 | return $header === '(request-target)' |
|||
419 | 6 | || $request->hasHeader($header) |
|||
420 | 6 | || ($header === 'date' && $request->hasHeader('x-date')); |
|||
421 | 6 | }) |
|||
422 | 6 | ->toArray(); |
|||
423 | } |
||||
424 | |||||
425 | /** |
||||
426 | * Get the algorithm to sign the request. |
||||
427 | * Assert that the algorithm is supported. |
||||
428 | * |
||||
429 | * @param string|null $algorithm |
||||
430 | * @return string |
||||
431 | * @throws \RuntimeException |
||||
432 | */ |
||||
433 | 8 | protected function getSignAlgorithm(?string $algorithm): string |
|||
434 | { |
||||
435 | 8 | if ($algorithm === null && count($this->supportedAlgorithms) > 1) { |
|||
436 | 1 | throw new \BadMethodCallException(sprintf('Multiple algorithms available; no algorithm specified')); |
|||
437 | } |
||||
438 | |||||
439 | 7 | if ($algorithm !== null && !in_array($algorithm, $this->supportedAlgorithms, true)) { |
|||
440 | 1 | throw new \InvalidArgumentException('Unsupported algorithm: ' . $algorithm); |
|||
441 | } |
||||
442 | |||||
443 | 6 | return $algorithm ?? $this->supportedAlgorithms[0]; |
|||
444 | } |
||||
445 | } |
||||
446 |