From 7f704f34fada3e0ea8a690fb7dbafbbae6d1203c Mon Sep 17 00:00:00 2001 From: Diogo Cordeiro Date: Tue, 7 Aug 2018 04:31:50 +0100 Subject: [PATCH] First draft of HTTP Signatures Inbox Validation --- actions/apinbox.php | 35 +++- utils/http-signature-auth.php | 348 ++++++++++++++++++++++++++++++++++ 2 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 utils/http-signature-auth.php diff --git a/actions/apinbox.php b/actions/apinbox.php index 40a68aa..a8127fe 100755 --- a/actions/apinbox.php +++ b/actions/apinbox.php @@ -37,6 +37,10 @@ if (!defined('GNUSOCIAL')) { * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 * @link http://www.gnu.org/software/social/ */ + +use GuzzleHttp\Psr7; +use HttpSignatures\Context; + class apInboxAction extends ManagedAction { protected $needLogin = false; @@ -71,8 +75,37 @@ class apInboxAction extends ManagedAction $headers = $this->get_all_headers(); common_debug('ActivityPub Inbox: Request Headers: '.print_r($headers, true)); + try { + $res = HTTPSignature::parse($headers); + common_debug('ActivityPub Inbox: Request Res: '.print_r($res, true)); + } catch (HttpSignatureError $e) { + common_debug('ActivityPub Inbox: HTTP Signature Error: '. $e->getMessage()); + ActivityPubReturn::error('HTTP Signature Error: '. $e->getMessage()); + } catch (Exception $e) { + ActivityPubReturn::error($e->getMessage()); + } - // TODO: Validate HTTP Signature, if it fails, attempt once with profile update + /*if (HTTPSignature::verify($res, + $actor_public_key, 'rsa') == FALSE) { + common_debug('ActivityPub Inbox: Could not authorize request.'); + ActivityPubReturn::error('Unauthorized.', 403); + }*/ + + $context = new Context([ + 'keys' => [$res['params']['keyId'] => $actor_public_key], + 'algorithm' => $res['params']['algorithm'], + 'headers' => $res['headers'], + ]); + + $request = new Psr7\Request($_SERVER['REQUEST_METHOD'], + (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]", + $headers); + + if ($context->verifier()->isValid($request) == false) + { + common_debug('ActivityPub Inbox: HTTP Signature: Unauthorized request.'); + ActivityPubReturn::error('Unauthorized.', 403); + } common_debug('ActivityPub Inbox: HTTP Signature: Authorized request. Will now start the inbox handler.'); diff --git a/utils/http-signature-auth.php b/utils/http-signature-auth.php new file mode 100644 index 0000000..89ff300 --- /dev/null +++ b/utils/http-signature-auth.php @@ -0,0 +1,348 @@ + + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +class HttpSignatureError extends Exception { }; +class ExpiredRequestError extends HttpSignatureError { }; +class InvalidHeaderError extends HttpSignatureError { }; +class InvalidParamsError extends HttpSignatureError { }; +class MissingHeaderError extends HttpSignatureError { }; +class InvalidAlgorithmError extends HttpSignatureError { }; + +class HTTPSignature { + + static function parse(array $headers, array $options = array()) + { + if (!array_key_exists('signature', $headers)) { + throw new MissingHeaderError('no signature header in the request'); + } + $auth = 'Signature '.$headers['signature']; + + if (!array_key_exists('headers', $options)) { + $options['headers'] = array(isset($headers['x-date']) ? 'x-date' : 'date'); + } else { + if (!is_array($options['headers'])) { + throw new Exception('headers option is not an array'); + } + if (sizeof(array_filter($options['headers'], function ($a) { return (!is_string($a)); }))) { + throw new Exception('headers option is not an array of strings'); + } + } + + if (!array_key_exists('clockSkew', $options)) { + $options['clockSkew'] = 300; + } elseif (!is_numeric($options['clockSkew'])) { + throw new Exception('clockSkew option is not numeric'); + } + + if (array_key_exists('algorithms', $options)) { + if (!is_array($options['algorithms'])) { + throw new Exception('algorithms option is not an array'); + } + if (sizeof(array_filter($options['algorithms'], function ($a) { return (!is_string($a)); }))) { + throw new Exception('algorithms option is not an array of strings'); + } + } + + $headers['request-line'] = array_key_exists('requestLine', $options) ? + $options['requestLine'] : + sprintf("%s %s %s", $_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'], $_SERVER['SERVER_PROTOCOL']); + + foreach ($options['headers'] as $header) { + if (!array_key_exists($header, $headers)) { + throw new MissingHeaderError("$header was not in the request"); + } + } + + $states = array( + 'start' => 0, + 'scheme' => 1, + 'space' => 2, + 'param' => 3, + 'quote' => 4, + 'value' => 5, + 'comma' => 6 + ); + + $scheme = ''; + $params = array(); + + $param = ''; + $value = ''; + $state = $states['start']; + + for ($i = 0; $i < strlen($auth); $i++) { + $ch = $auth[$i]; + + switch ($state) { + case $states['start']: + if (ctype_space($ch)) { + break; + } + + $state = $states['scheme']; + /* FALLTHROUGH */ + case $states['scheme']: + if (ctype_space($ch)) { + $state = $states['space']; + } else { + $scheme .= $ch; + } + + break; + + case $states['space']; + if (ctype_space($ch)) { + continue; + } + + $state = $states['param']; + /* FALLTHROUGH */ + case $states['param']: + if ($ch === '=') { + if ($param === '') { + throw new InvalidHeaderError('bad param name'); + } + if (array_key_exists($param, $params)) { + throw new InvalidHeaderError('param specified again'); + } + + $state = $states['quote']; + break; + } + if (!ctype_alpha($ch)) { + throw new InvalidHeaderError('bad param format'); + } + + $param .= $ch; + break; + + case $states['quote']; + if ($ch !== '"') { + throw new InvalidHeaderError('bad param format'); + } + $state = $states['value']; + break; + + case $states['value']: + if ($ch === '"') { + $params[$param] = $value; + $param = ''; + $value = ''; + + $state = $states['comma']; + break; + } + + $value .= $ch; + break; + + case $states['comma']: + if ($ch !== ',') { + throw new InvalidHeaderError('bad param format'); + } + + $state = $states['param']; + break; + + default: + throw new Error('invalid state'); + } + } + + if ($state !== $states['comma']) { + throw new InvalidHeaderError("bad param format"); + } + + if ($scheme !== 'Signature') { + throw new InvalidHeaderError('scheme was not "Signature"'); + } + $required = array('keyId', 'algorithm', 'signature'); + foreach ($required as $param) { + if (!array_key_exists($param, $params)) { + throw new InvalidHeaderError("$param was not specified"); + } + } + + if (array_key_exists('headers', $params)) { + $params['headers'] = explode(' ', $params['headers']); + } else { + $params['headers'] = array(isset($headers['x-date']) ? 'x-date' : 'date'); + } + + foreach ($options['headers'] as $header) { + if (!in_array($header, $params['headers'])) { + throw new MissingHeaderError("$header was not a signed header"); + } + } + + if (isset($options['algorithms']) && !in_array($params['algorithm'], $options['algorithms'])) { + throw new InvalidParamsError($params['algorithm'] . " is not a supported algorithm"); + } + + $date = null; + if (isset($headers['date'])) { + $date = strtotime($headers['date']); + } elseif (isset($headers['x-date'])) { + $date = strtotime($headers['x-date']); + } + if (!is_null($date)) { + if ($date === FALSE) { + throw new InvalidHeaderError('unable to parse date header'); + } + $skew = abs(time() - $date); + if ($skew > $options['clockSkew']) { + throw new ExpiredRequestError(sprintf("clock skew of %ds was greater than %ds", $skew, $options['clockSkew'])); + } + } + + $sign = array(); + foreach ($params['headers'] as $header) { + $sign[] = $header === 'request-line' ? $headers['request-line'] : sprintf("%s: %s", $header, $headers[$header]); + } + + return (array('scheme' => $scheme, 'params' => $params, 'signingString' => implode("\n", $sign))); + } + + static function verify(array $res, $key, $keytype) + { + if (!is_string($key)) { + throw new Exception('key is not a string'); + } + + $alg = explode('-', $res['params']['algorithm'], 2); + if (sizeof($alg) != 2) { + throw new InvalidAlgorithmError("unsupported algorithm"); + } + if ($alg[0] != $keytype) { + throw new InvalidAlgorithmError("algorithm type doesn't match key type"); + } + switch ($alg[0]) { + case 'rsa': + $map = array('sha1' => OPENSSL_ALGO_SHA1, 'sha256' => OPENSSL_ALGO_SHA256, 'sha512' => OPENSSL_ALGO_SHA512); + if (!array_key_exists($alg[1], $map)) { + throw new InvalidAlgorithmError('unsupported algorithm'); + } + $pkey = openssl_get_publickey($key); + if ($pkey === FALSE) { + throw new Exception('key could not be parsed'); + } + + $rv = openssl_verify($res['signingString'], base64_decode($res['params']['signature']), $pkey, $map[$alg[1]]); + openssl_free_key($pkey); + + switch ($rv) { + case 0: + return (FALSE); + case 1: + return (TRUE); + default: + throw new Exception('key could not be verified'); + } + break; + + case 'hmac': + return (hash_hmac($alg[1], $res['signingString'], $key, true) === base64_decode($res['params']['signature'])); + break; + default: + throw new InvalidAlgorithmError("unsupported algorithm"); + } + } + + static function sign(&$headers = array(), array $options = array()) + { + if (is_null($headers)) { + $headers = array(); + } elseif (!is_array($headers)) { + throw new Exception('headers are not an array'); + } + + if (!array_key_exists('keyId', $options)) { + throw new Exception('keyId option is missing'); + } elseif (!is_string($options['keyId'])) { + throw new Exception('keyId option is not a string'); + } + if (!array_key_exists('key', $options)) { + throw new Exception('key option is missing'); + } elseif (!is_string($options['key'])) { + throw new Exception('key option is not a string'); + } + + if (!array_key_exists('headers', $options)) { + $options['headers'] = array('date'); + } else { + if (!is_array($options['headers'])) { + throw new Exception('headers option is not an array'); + } + if (sizeof(array_filter($options['headers'], function ($a) { return (!is_string($a)); }))) { + throw new Exception('headers option is not an array of strings'); + } + } + + if (!array_key_exists('algorithm', $options)) { + $options['algorithm'] = 'rsa-sha256'; + } + + if (!array_key_exists('date', $headers)) { + $headers['date'] = date(DATE_RFC1123); + } + + $headers['request-line'] = array_key_exists('requestLine', $options) ? + $options['requestLine'] : + sprintf("%s %s %s", $_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'], $_SERVER['SERVER_PROTOCOL']); + + $sign = array(); + foreach ($options['headers'] as $header) { + if (!array_key_exists($header, $headers)) { + throw new MissingHeaderError("$header was not in the request"); + } + $sign[] = $header === 'request-line' ? $headers['request-line'] : sprintf("%s: %s", $header, $headers[$header]); + } + $data = join("\n", $sign); + + $alg = explode('-', $options['algorithm'], 2); + if (sizeof($alg) != 2) { + throw new InvalidAlgorithmError("unsupported algorithm"); + } + switch ($alg[0]) { + case 'rsa': + $map = array('sha1' => OPENSSL_ALGO_SHA1, 'sha256' => OPENSSL_ALGO_SHA256, 'sha512' => OPENSSL_ALGO_SHA512); + if (!array_key_exists($alg[1], $map)) { + throw new InvalidAlgorithmError('unsupported algorithm'); + } + $key = openssl_get_privatekey($options['key']); + if ($key === FALSE) { + error_log(openssl_error_string()); + throw new Exception('key option could not be parsed'); + } + + if (openssl_sign($data, $signature, $key, $map[$alg[1]]) === FALSE) { + throw new Exception('unable to sign'); + } + break; + + case 'hmac': + $signature = hash_hmac($alg[1], $data, $options['key'], true); + break; + default: + throw new InvalidAlgorithmError("unsupported algorithm"); + } + unset($headers['request-line']); + $headers['authorization'] = sprintf('Signature keyId="%s",algorithm="%s",headers="%s",signature="%s"', + $options['keyId'], $options['algorithm'], implode(' ', $options['headers']), + base64_encode($signature)); + } +}