commit cefeda7908cfd397f5bbdb7d09868d9920049440 Author: Fred Date: Sat Oct 16 06:55:42 2021 +0800 fork diff --git a/.php_cs b/.php_cs new file mode 100644 index 0000000..781e811 --- /dev/null +++ b/.php_cs @@ -0,0 +1,20 @@ +in(__DIR__ . "/src"); + +return Symfony\CS\Config\Config::create() + ->level(\Symfony\CS\FixerInterface::PSR2_LEVEL) + ->fixers([ + 'unused_use', + 'remove_lines_between_uses', + 'remove_leading_slash_use', + 'ordered_use', + 'short_array_syntax', + 'whitespacy_lines', + 'ternary_spaces', + 'standardize_not_equal', + 'spaces_cast', + 'extra_empty_lines', + ]) + ->finder($finder); diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0d748e5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2014-2015 99designs + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..fa38db7 --- /dev/null +++ b/composer.json @@ -0,0 +1,35 @@ +{ + "name": "99designs/http-signatures", + "description": "Sign and verify HTTP messages", + "keywords": ["http", "https", "signing", "signed", "signature", "hmac"], + "license": "MIT", + "authors": [ + { + "name": "Paul Annesley", + "email": "paul@99designs.com" + } + ], + "autoload": { + "psr-4": { + "HttpSignatures\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "HttpSignatures\\Tests\\": "tests/" + } + }, + "require": { + "php": ">=5.5", + "paragonie/random_compat": "^1.0|^2.0", + "psr/http-message": "^1.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^1.11", + "guzzlehttp/psr7": "^1.2", + "phpunit/phpunit": "~4.8", + "symfony/http-foundation": "~2.8|~3.0", + "symfony/psr-http-message-bridge": "^1.0", + "zendframework/zend-diactoros": "^1.1" + } +} diff --git a/src/Algorithm.php b/src/Algorithm.php new file mode 100644 index 0000000..a3f6dc4 --- /dev/null +++ b/src/Algorithm.php @@ -0,0 +1,26 @@ + keySecret + $this->keys = $args['keys']; + } elseif (isset($args['keyStore'])) { + $this->setKeyStore($args['keyStore']); + } + + // algorithm for signing; not necessary for verifying. + if (isset($args['algorithm'])) { + $this->algorithmName = $args['algorithm']; + } + + // headers list for signing; not necessary for verifying. + if (isset($args['headers'])) { + $this->headers = $args['headers']; + } + + // signingKeyId specifies the key used for signing messages. + if (isset($args['signingKeyId'])) { + $this->signingKeyId = $args['signingKeyId']; + } elseif (isset($args['keys']) && count($args['keys']) === 1) { + list($this->signingKeyId) = array_keys($args['keys']); // first key + } + } + + /** + * @return Signer + * + * @throws Exception + */ + public function signer() + { + return new Signer( + $this->signingKey(), + $this->algorithm(), + $this->headerList() + ); + } + + /** + * @return Verifier + */ + public function verifier() + { + return new Verifier($this->keyStore()); + } + + /** + * @return Key + * + * @throws Exception + * @throws KeyStoreException + */ + private function signingKey() + { + if (isset($this->signingKeyId)) { + return $this->keyStore()->fetch($this->signingKeyId); + } else { + throw new Exception('no implicit or specified signing key'); + } + } + + /** + * @return HmacAlgorithm + * + * @throws Exception + */ + private function algorithm() + { + return Algorithm::create($this->algorithmName); + } + + /** + * @return HeaderList + */ + private function headerList() + { + return new HeaderList($this->headers); + } + + /** + * @return KeyStore + */ + private function keyStore() + { + if (empty($this->keyStore)) { + $this->keyStore = new KeyStore($this->keys); + } + + return $this->keyStore; + } + + /** + * @param KeyStoreInterface $keyStore + */ + private function setKeyStore(KeyStoreInterface $keyStore) + { + $this->keyStore = $keyStore; + } +} diff --git a/src/Exception.php b/src/Exception.php new file mode 100644 index 0000000..c8da0b0 --- /dev/null +++ b/src/Exception.php @@ -0,0 +1,7 @@ +names = array_map( + [$this, 'normalize'], + $names + ); + } + + /** + * @param $string + * + * @return HeaderList + */ + public static function fromString($string) + { + return new static(explode(' ', $string)); + } + + /** + * @return string + */ + public function string() + { + return implode(' ', $this->names); + } + + /** + * @param $name + * + * @return string + */ + private function normalize($name) + { + return strtolower($name); + } +} diff --git a/src/HmacAlgorithm.php b/src/HmacAlgorithm.php new file mode 100644 index 0000000..c7bc613 --- /dev/null +++ b/src/HmacAlgorithm.php @@ -0,0 +1,36 @@ +digestName = $digestName; + } + + /** + * @return string + */ + public function name() + { + return sprintf('hmac-%s', $this->digestName); + } + + /** + * @param string $key + * @param string $data + * + * @return string + */ + public function sign($key, $data) + { + return hash_hmac($this->digestName, $data, $key, true); + } +} diff --git a/src/Key.php b/src/Key.php new file mode 100644 index 0000000..a7d7b7d --- /dev/null +++ b/src/Key.php @@ -0,0 +1,22 @@ +id = $id; + $this->secret = $secret; + } +} diff --git a/src/KeyStore.php b/src/KeyStore.php new file mode 100644 index 0000000..27ff1fd --- /dev/null +++ b/src/KeyStore.php @@ -0,0 +1,36 @@ +keys = []; + foreach ($keys as $id => $secret) { + $this->keys[$id] = new Key($id, $secret); + } + } + + /** + * @param string $keyId + * + * @return Key + * + * @throws KeyStoreException + */ + public function fetch($keyId) + { + if (isset($this->keys[$keyId])) { + return $this->keys[$keyId]; + } else { + throw new KeyStoreException("Key '$keyId' not found"); + } + } +} diff --git a/src/KeyStoreException.php b/src/KeyStoreException.php new file mode 100644 index 0000000..c742a79 --- /dev/null +++ b/src/KeyStoreException.php @@ -0,0 +1,7 @@ +key = $key; + $this->algorithm = $algorithm; + $this->signingString = new SigningString($headerList, $message); + } + + public function string() + { + return $this->algorithm->sign( + $this->key->secret, + $this->signingString->string() + ); + } +} diff --git a/src/SignatureParameters.php b/src/SignatureParameters.php new file mode 100644 index 0000000..5e95dae --- /dev/null +++ b/src/SignatureParameters.php @@ -0,0 +1,49 @@ +key = $key; + $this->algorithm = $algorithm; + $this->headerList = $headerList; + $this->signature = $signature; + } + + /** + * @return string + */ + public function string() + { + return implode(',', $this->parameterComponents()); + } + + /** + * @return array + */ + private function parameterComponents() + { + return [ + sprintf('keyId="%s"', $this->key->id), + sprintf('algorithm="%s"', $this->algorithm->name()), + sprintf('headers="%s"', $this->headerList->string()), + sprintf('signature="%s"', $this->signatureBase64()), + ]; + } + + /** + * @return string + */ + private function signatureBase64() + { + return base64_encode($this->signature->string()); + } +} diff --git a/src/SignatureParametersParser.php b/src/SignatureParametersParser.php new file mode 100644 index 0000000..698ef3b --- /dev/null +++ b/src/SignatureParametersParser.php @@ -0,0 +1,111 @@ +input = $input; + } + + /** + * @return array + */ + public function parse() + { + $result = $this->pairsToAssociative( + $this->arrayOfPairs() + ); + $this->validate($result); + + return $result; + } + + /** + * @param array $pairs + * + * @return array + */ + private function pairsToAssociative($pairs) + { + $result = []; + foreach ($pairs as $pair) { + $result[$pair[0]] = $pair[1]; + } + + return $result; + } + + /** + * @return array + */ + private function arrayOfPairs() + { + return array_map( + [$this, 'pair'], + $this->segments() + ); + } + + /** + * @return array + */ + private function segments() + { + return explode(',', $this->input); + } + + /** + * @param $segment + * + * @return array + * + * @throws SignatureParseException + */ + private function pair($segment) + { + $segmentPattern = '/\A(keyId|algorithm|headers|signature)="(.*)"\z/'; + $matches = []; + $result = preg_match($segmentPattern, $segment, $matches); + if ($result !== 1) { + throw new SignatureParseException("Signature parameters segment '$segment' invalid"); + } + array_shift($matches); + + return $matches; + } + + /** + * @param $result + * + * @throws SignatureParseException + */ + private function validate($result) + { + $this->validateAllKeysArePresent($result); + } + + /** + * @param $result + * + * @throws SignatureParseException + */ + private function validateAllKeysArePresent($result) + { + // Regexp in pair() ensures no unwanted keys exist. + // Ensure that all wanted keys exist. + $wanted = ['keyId', 'algorithm', 'headers', 'signature']; + $missing = array_diff($wanted, array_keys($result)); + if (!empty($missing)) { + $csv = implode(', ', $missing); + throw new SignatureParseException("Missing keys $csv"); + } + } +} diff --git a/src/SignatureParseException.php b/src/SignatureParseException.php new file mode 100644 index 0000000..1ec7090 --- /dev/null +++ b/src/SignatureParseException.php @@ -0,0 +1,7 @@ +key = $key; + $this->algorithm = $algorithm; + $this->headerList = $headerList; + } + + /** + * @param RequestInterface $message + * @return RequestInterface + */ + public function sign($message) + { + $signatureParameters = $this->signatureParameters($message); + $message = $message->withAddedHeader("Signature", $signatureParameters->string()); + $message = $message->withAddedHeader("Authorization", "Signature " . $signatureParameters->string()); + return $message; + } + + /** + * @param RequestInterface $message + * @return SignatureParameters + */ + private function signatureParameters($message) + { + return new SignatureParameters( + $this->key, + $this->algorithm, + $this->headerList, + $this->signature($message) + ); + } + + /** + * @param RequestInterface $message + * @return Signature + */ + private function signature($message) + { + return new Signature( + $message, + $this->key, + $this->algorithm, + $this->headerList + ); + } +} diff --git a/src/SigningString.php b/src/SigningString.php new file mode 100644 index 0000000..ec3c102 --- /dev/null +++ b/src/SigningString.php @@ -0,0 +1,84 @@ +headerList = $headerList; + $this->message = $message; + } + + /** + * @return string + */ + public function string() + { + return implode("\n", $this->lines()); + } + + /** + * @return array + */ + private function lines() + { + return array_map( + [$this, 'line'], + $this->headerList->names + ); + } + + /** + * @param string $name + * @return string + * @throws SignedHeaderNotPresentException + */ + private function line($name) + { + if ($name == '(request-target)') { + return $this->requestTargetLine(); + } else { + return sprintf('%s: %s', $name, $this->headerValue($name)); + } + } + + /** + * @param string $name + * @return string + * @throws SignedHeaderNotPresentException + */ + private function headerValue($name) + { + if ($this->message->hasHeader($name)) { + $header = $this->message->getHeader($name); + return end($header); + } else { + throw new SignedHeaderNotPresentException("Header '$name' not in message"); + } + } + + /** + * @return string + */ + private function requestTargetLine() + { + return sprintf( + '(request-target): %s %s', + strtolower($this->message->getMethod()), + $this->message->getRequestTarget() + ); + } +} diff --git a/src/Verification.php b/src/Verification.php new file mode 100644 index 0000000..9537fde --- /dev/null +++ b/src/Verification.php @@ -0,0 +1,181 @@ +message = $message; + $this->keyStore = $keyStore; + } + + /** + * @return bool + */ + public function isValid() + { + return $this->hasSignatureHeader() && $this->signatureMatches(); + } + + /** + * @return bool + */ + private function signatureMatches() + { + try { + $random = random_bytes(32); + return hash_hmac('sha256', $this->expectedSignatureBase64(), $random, true) === hash_hmac('sha256', $this->providedSignatureBase64(), $random, true); + } catch (SignatureParseException $e) { + return false; + } catch (KeyStoreException $e) { + return false; + } catch (SignedHeaderNotPresentException $e) { + return false; + } + } + + /** + * @return string + */ + private function expectedSignatureBase64() + { + return base64_encode($this->expectedSignature()->string()); + } + + /** + * @return Signature + */ + private function expectedSignature() + { + return new Signature( + $this->message, + $this->key(), + $this->algorithm(), + $this->headerList() + ); + } + + /** + * @return string + * + * @throws Exception + */ + private function providedSignatureBase64() + { + return $this->parameter('signature'); + } + + /** + * @return Key + * + * @throws Exception + */ + private function key() + { + return $this->keyStore->fetch($this->parameter('keyId')); + } + + /** + * @return HmacAlgorithm + * + * @throws Exception + */ + private function algorithm() + { + return Algorithm::create($this->parameter('algorithm')); + } + + /** + * @return HeaderList + * + * @throws Exception + */ + private function headerList() + { + return HeaderList::fromString($this->parameter('headers')); + } + + /** + * @param string $name + * + * @return string + * + * @throws Exception + */ + private function parameter($name) + { + $parameters = $this->parameters(); + if (!isset($parameters[$name])) { + throw new Exception("Signature parameters does not contain '$name'"); + } + + return $parameters[$name]; + } + + /** + * @return array + * + * @throws Exception + */ + private function parameters() + { + if (!isset($this->_parameters)) { + $parser = new SignatureParametersParser($this->signatureHeader()); + $this->_parameters = $parser->parse(); + } + + return $this->_parameters; + } + + /** + * @return bool + */ + private function hasSignatureHeader() + { + return $this->message->hasHeader('Signature') || $this->message->hasHeader('Authorization'); + } + + /** + * @return string + * + * @throws Exception + */ + private function signatureHeader() + { + if ($signature = $this->fetchHeader('Signature')) { + return $signature; + } elseif ($authorization = $this->fetchHeader('Authorization')) { + return substr($authorization, strlen('Signature ')); + } else { + throw new Exception('HTTP message has no Signature or Authorization header'); + } + } + + /** + * @param $name + * + * @return string|null + */ + private function fetchHeader($name) + { + // grab the most recently set header. + $header = $this->message->getHeader($name); + return end($header); + } +} diff --git a/src/Verifier.php b/src/Verifier.php new file mode 100644 index 0000000..7047287 --- /dev/null +++ b/src/Verifier.php @@ -0,0 +1,30 @@ +keyStore = $keyStore; + } + + /** + * @param RequestInterface $message + * @return bool + */ + public function isValid($message) + { + $verification = new Verification($message, $this->keyStore); + + return $verification->isValid(); + } +}