Failed Conditions
Push — master ( 3d81ff...b5ee61 )
by Arnold
03:17
created

HttpSignature::withAlgorithm()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 14
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 3

Importance

Changes 0
Metric Value
cc 3
eloc 7
nc 3
nop 1
dl 0
loc 14
ccs 8
cts 8
cp 1
crap 3
rs 10
c 0
b 0
f 0
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
The constant Improved\FUNCTION_ARGUMENT_PLACEHOLDER was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
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
Bug introduced by
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 ignore-call  annotation

143
            ->map(/** @scrutinizer ignore-call */ i\function_partial('strtolower', __))
Loading history...
Bug introduced by
The constant __ was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
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'));
1 ignored issue
show
Bug introduced by
The function type_check 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 ignore-call  annotation

231
        /** @scrutinizer ignore-call */ 
232
        i\type_check($rawSignature, 'string', new \UnexpectedValueException('Expected %2$s, %1$s given'));
Loading history...
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]);
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
Bug introduced by
The constant __ was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
Bug introduced by
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 ignore-call  annotation

326
            ->map(/** @scrutinizer ignore-call */ i\function_partial('strtolower', __))
Loading history...
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::createFromTimeString($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) {
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