From 1c7f4e3c6331f8ad7b1987f33a17074908ad41eb Mon Sep 17 00:00:00 2001 From: rabuzarus Date: Mon, 18 Jun 2018 23:05:44 +0200 Subject: [PATCH] port hubzillas OpenWebAuth - remote authentification --- boot.php | 2 +- database.sql | 17 +- doc/Addons.md | 8 + index.php | 42 ++-- mod/xrd.php | 6 +- src/Core/System.php | 10 +- src/Database/DBStructure.php | 14 ++ src/Model/Profile.php | 156 +++++++++++++-- src/Model/Verify.php | 73 +++++++ src/Module/Magic.php | 121 ++++++++++++ src/Module/Owa.php | 94 +++++++++ src/Network/Probe.php | 17 +- src/Util/Crypto.php | 218 +++++++++++++++++++++ src/Util/HTTPHeaders.php | 59 ++++++ src/Util/HTTPSig.php | 352 ++++++++++++++++++++++++++++++++++ view/templates/xrd_person.tpl | 3 + 16 files changed, 1151 insertions(+), 41 deletions(-) create mode 100644 src/Model/Verify.php create mode 100644 src/Module/Magic.php create mode 100644 src/Module/Owa.php create mode 100644 src/Util/HTTPHeaders.php create mode 100644 src/Util/HTTPSig.php diff --git a/boot.php b/boot.php index 79ec53abf5..46bb2a3f8e 100644 --- a/boot.php +++ b/boot.php @@ -41,7 +41,7 @@ define('FRIENDICA_PLATFORM', 'Friendica'); define('FRIENDICA_CODENAME', 'The Tazmans Flax-lily'); define('FRIENDICA_VERSION', '2018.08-dev'); define('DFRN_PROTOCOL_VERSION', '2.23'); -define('DB_UPDATE_VERSION', 1268); +define('DB_UPDATE_VERSION', 1269); define('NEW_UPDATE_ROUTINE_VERSION', 1170); /** diff --git a/database.sql b/database.sql index b186c0c0aa..b871ce2deb 100644 --- a/database.sql +++ b/database.sql @@ -1,6 +1,6 @@ -- ------------------------------------------ -- Friendica 2018.08-dev (The Tazmans Flax-lily) --- DB_UPDATE_VERSION 1268 +-- DB_UPDATE_VERSION 1269 -- ------------------------------------------ @@ -375,7 +375,7 @@ CREATE TABLE IF NOT EXISTS `group` ( CREATE TABLE IF NOT EXISTS `group_member` ( `id` int unsigned NOT NULL auto_increment COMMENT 'sequential ID', `gid` int unsigned NOT NULL DEFAULT 0 COMMENT 'groups.id of the associated group', - `contact-id` int unsigned NOT NULL DEFAULT 0 COMMENT 'contact.id of the member assigned to the associated group', + `contact-id` int unsigned NOT NULL DEFAULT 0 COMMENT 'contact.id of the member assigned to the associated group', PRIMARY KEY(`id`), INDEX `contactid` (`contact-id`), UNIQUE INDEX `gid_contactid` (`gid`,`contact-id`) @@ -1084,6 +1084,19 @@ CREATE TABLE IF NOT EXISTS `user-item` ( PRIMARY KEY(`uid`,`iid`) ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='User specific item data'; +-- +-- TABLE verify +-- +CREATE TABLE IF NOT EXISTS `verify` ( + `id` int(10) NOT NULL auto_increment COMMENT 'sequential ID', + `uid` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'User id', + `type` varchar(32) DEFAULT '' COMMENT 'Verify type', + `token` varchar(255) DEFAULT '' COMMENT 'A generated token', + `meta` varchar(255) DEFAULT '' COMMENT '', + `created` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'datetime of creation', + PRIMARY KEY(`id`) +) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Store token to verify contacts'; + -- -- TABLE worker-ipc -- diff --git a/doc/Addons.md b/doc/Addons.md index 22b34fa62b..090d5a9d77 100644 --- a/doc/Addons.md +++ b/doc/Addons.md @@ -357,6 +357,13 @@ Hook data: 'item' => item array (input) 'html' => converted item body (input/output) +### 'magic_auth_success' +Called when a magic-auth was successful. +Hook data: + 'visitor' => array with the contact record of the visitor + 'url' => the query string + 'session' => $_SESSION array + Current JavaScript hooks ------------- @@ -557,6 +564,7 @@ Here is a complete list of all hook callbacks with file locations (as of 01-Apr- Addon::callHooks('profile_sidebar', $arr); Addon::callHooks('profile_tabs', $arr); Addon::callHooks('zrl_init', $arr); + Addon::callHooks('magic_auth_success', $arr); ### src/Model/Event.php diff --git a/index.php b/index.php index aeda999825..c0290dff4d 100644 --- a/index.php +++ b/index.php @@ -121,25 +121,35 @@ if ((x($_SESSION, 'language')) && ($_SESSION['language'] !== $lang)) { L10n::loadTranslationTable($lang); } -if ((x($_GET, 'zrl')) && $a->mode == App::MODE_NORMAL) { - // Only continue when the given profile link seems valid - // Valid profile links contain a path with "/profile/" and no query parameters - if ((parse_url($_GET['zrl'], PHP_URL_QUERY) == "") - && strstr(parse_url($_GET['zrl'], PHP_URL_PATH), "/profile/") - ) { - $_SESSION['my_url'] = $_GET['zrl']; - $a->query_string = preg_replace('/[\?&]zrl=(.*?)([\?&]|$)/is', '', $a->query_string); - Profile::zrlInit($a); - } else { - // Someone came with an invalid parameter, maybe as a DDoS attempt - // We simply stop processing here - logger("Invalid ZRL parameter ".$_GET['zrl'], LOGGER_DEBUG); - header('HTTP/1.1 403 Forbidden'); - echo "

403 Forbidden

"; - killme(); +if ((x($_GET,'zrl')) && $a->mode == App::MODE_NORMAL) { + $a->query_string = Profile::stripZrls($a->query_string); + if (!local_user()) { + // Only continue when the given profile link seems valid + // Valid profile links contain a path with "/profile/" and no query parameters + if ((parse_url($_GET['zrl'], PHP_URL_QUERY) == "") && + strstr(parse_url($_GET['zrl'], PHP_URL_PATH), "/profile/")) { + if ($_SESSION["visitor_home"] != $_GET["zrl"]) { + $_SESSION['my_url'] = $_GET['zrl']; + $_SESSION['authenticated'] = 0; + } + Profile::zrlInit($a); + } else { + // Someone came with an invalid parameter, maybe as a DDoS attempt + // We simply stop processing here + logger("Invalid ZRL parameter " . $_GET['zrl'], LOGGER_DEBUG); + header('HTTP/1.1 403 Forbidden'); + echo "

403 Forbidden

"; + killme(); + } } } +if ((x($_GET,'owt')) && $a->mode == App::MODE_NORMAL) { + $token = $_GET['owt']; + $a->query_string = Profile::stripQueryParam($a->query_string, 'owt'); + Profile::owtInit($token); +} + /** * For Mozilla auth manager - still needs sorting, and this might conflict with LRDD header. * Apache/PHP lumps the Link: headers into one - and other services might not be able to parse it diff --git a/mod/xrd.php b/mod/xrd.php index bbfd7ce64a..2d19bb3b7c 100644 --- a/mod/xrd.php +++ b/mod/xrd.php @@ -78,7 +78,8 @@ function xrd_json($a, $uri, $alias, $profile_url, $r) ['rel' => 'http://salmon-protocol.org/ns/salmon-replies', 'href' => System::baseUrl().'/salmon/'.$r['nickname']], ['rel' => 'http://salmon-protocol.org/ns/salmon-mention', 'href' => System::baseUrl().'/salmon/'.$r['nickname'].'/mention'], ['rel' => 'http://ostatus.org/schema/1.0/subscribe', 'template' => System::baseUrl().'/follow?url={uri}'], - ['rel' => 'magic-public-key', 'href' => 'data:application/magic-public-key,'.$salmon_key] + ['rel' => 'magic-public-key', 'href' => 'data:application/magic-public-key,'.$salmon_key], + array('rel' => 'http://purl.org/openwebauth/v1', 'type' => 'application/x-dfrn+json', 'href' => System::baseUrl().'/owa') ]]; echo json_encode($json); killme(); @@ -102,10 +103,11 @@ function xrd_xml($a, $uri, $alias, $profile_url, $r) '$atom' => System::baseUrl() . '/dfrn_poll/' . $r['nickname'], '$poco_url' => System::baseUrl() . '/poco/' . $r['nickname'], '$photo' => System::baseUrl() . '/photo/profile/' . $r['uid'] . '.jpg', - '$baseurl' => System::baseUrl(), + '$baseurl' => System::baseUrl(), '$salmon' => System::baseUrl() . '/salmon/' . $r['nickname'], '$salmen' => System::baseUrl() . '/salmon/' . $r['nickname'] . '/mention', '$subscribe' => System::baseUrl() . '/follow?url={uri}', + '$openwebauth' => System::baseUrl() .'/owa', '$modexp' => 'data:application/magic-public-key,' . $salmon_key] ); diff --git a/src/Core/System.php b/src/Core/System.php index 1db417eb88..ded781da83 100644 --- a/src/Core/System.php +++ b/src/Core/System.php @@ -163,17 +163,17 @@ EOT; } /** - * @brief Encodes content to json + * @brief Encodes content to json. * * This function encodes an array to json format * and adds an application/json HTTP header to the output. * After finishing the process is getting killed. * - * @param array $x The input content + * @param array $x The input content. + * @param string $content_type Type of the input (Default: 'application/json'). */ - public static function jsonExit($x) - { - header("content-type: application/json"); + public static function jsonExit($x, $content_type = 'application/json') { + header("Content-type: $content_type"); echo json_encode($x); killme(); } diff --git a/src/Database/DBStructure.php b/src/Database/DBStructure.php index d4419553c4..33babded93 100644 --- a/src/Database/DBStructure.php +++ b/src/Database/DBStructure.php @@ -1818,6 +1818,20 @@ class DBStructure "PRIMARY" => ["uid", "iid"], ] ]; + $database["verify"] = [ + "comment" => "Store token to verify contacts", + "fields" => [ + "id" => ["type" => "int(10)", "not null" => "1", "extra" => "auto_increment", "primary" => "1", "comment" => "sequential ID"], + "uid" => ["type" => "int(10) unsigned", "not null" => "1", "default" => "0", "relation" => ["user" => "uid"], "comment" => "User id"], + "type" => ["type" => "varchar(32)", "not_null", "default" => "", "comment" => "Verify type"], + "token" => ["type" => "varchar(255)", "not_null" => "1", "default" => "", "comment" => "A generated token"], + "meta" => ["type" => "varchar(255)", "not_null" => "1", "default" => "", "comment" => ""], + "created" => ["type" => "datetime", "not null" => "1", "default" => NULL_DATE, "comment" => "datetime of creation"], + ], + "indexes" => [ + "PRIMARY" => ["id"], + ] + ]; $database["worker-ipc"] = [ "comment" => "Inter process communication between the frontend and the worker", "fields" => [ diff --git a/src/Model/Profile.php b/src/Model/Profile.php index 39a89694a8..cb1a15afd3 100644 --- a/src/Model/Profile.php +++ b/src/Model/Profile.php @@ -17,7 +17,9 @@ use Friendica\Core\System; use Friendica\Core\Worker; use Friendica\Database\DBM; use Friendica\Model\Contact; +use Friendica\Model\Verify; use Friendica\Protocol\Diaspora; +use Friendica\Network\Probe; use Friendica\Util\DateTimeFormat; use Friendica\Util\Network; use Friendica\Util\Temporal; @@ -978,27 +980,137 @@ class Profile return null; } + /** + * Process the 'zrl' parameter and initiate the remote authentication. + * + * This method checks if the visitor has a public contact entry and + * redirects the visitor to his/her instance to start the magic auth (Authentication) + * process. + * + * @param App $a Application instance. + */ public static function zrlInit(App $a) { $my_url = self::getMyURL(); $my_url = Network::isUrlValid($my_url); + if ($my_url) { - // Is it a DDoS attempt? - // The check fetches the cached value from gprobe to reduce the load for this system - $urlparts = parse_url($my_url); + if (!local_user()) { + // Is it a DDoS attempt? + // The check fetches the cached value from gprobe to reduce the load for this system + $urlparts = parse_url($my_url); - $result = Cache::get('gprobe:' . $urlparts['host']); - if ((!is_null($result)) && (in_array($result['network'], [NETWORK_FEED, NETWORK_PHANTOM]))) { - logger('DDoS attempt detected for ' . $urlparts['host'] . ' by ' . $_SERVER['REMOTE_ADDR'] . '. server data: ' . print_r($_SERVER, true), LOGGER_DEBUG); - return; + $result = Cache::get('gprobe:' . $urlparts['host']); + if ((!is_null($result)) && (in_array($result['network'], [NETWORK_FEED, NETWORK_PHANTOM]))) { + logger('DDoS attempt detected for ' . $urlparts['host'] . ' by ' . $_SERVER['REMOTE_ADDR'] . '. server data: ' . print_r($_SERVER, true), LOGGER_DEBUG); + return; + } + + Worker::add(PRIORITY_LOW, 'GProbe', $my_url); + $arr = ['zrl' => $my_url, 'url' => $a->cmd]; + Addon::callHooks('zrl_init', $arr); + + // Try to find the public contact entry of the visitor. + $fields = ["id", "url"]; + $condition = ['uid' => 0, 'nurl' => normalise_link($my_url)]; + + $contact = dba::selectFirst('contact',$fields, $condition); + + // Not found? Try to probe the visitor. + if (!DBM::is_result($contact)) { + Probe::uri($my_url, '', -1, true, true); + $contact = dba::selectFirst('contact',$fields, $condition); + } + + if (!DBM::is_result($contact)) { + logger('No contact record found for ' . $my_url, LOGGER_DEBUG); + return; + } + + if (DBM::is_result($contact) && remote_user() && remote_user() === $contact['id']) { + // The visitor is already authenticated. + return; + } + + logger('Not authenticated. Invoking reverse magic-auth for ' . $my_url, LOGGER_DEBUG); + + // Try to avoid recursion - but send them home to do a proper magic auth. + $query = str_replace(array('?zrl=', '&zid='), array('?rzrl=', '&rzrl='), $a->query_string); + // The other instance needs to know where to redirect. + $dest = urlencode(System::baseUrl() . "/" . $query); + + // We need to extract the basebath from the profile url + // to redirect the visitors '/magic' module. + // Note: We should have the basepath of a contact also in the contact table. + $urlarr = explode("/profile/", $contact['url']); + $basepath = $urlarr[0]; + + if ($basepath != System::baseUrl() && !strstr($dest, '/magic') && !strstr($dest, '/rmagic')) { + goaway($basepath . '/magic' . '?f=&owa=1&dest=' . $dest); + } } - - Worker::add(PRIORITY_LOW, 'GProbe', $my_url); - $arr = ['zrl' => $my_url, 'url' => $a->cmd]; - Addon::callHooks('zrl_init', $arr); } } + /** + * OpenWebAuth authentication. + * + * @param string $token + */ + public static function owtInit($token) + { + $a = get_app(); + + // Clean old verify entries. + Verify::purge('owt', '3 MINUTE'); + + // Check if the token we got is the same one + // we have stored in the database. + $visitor_handle = Verify::getMeta('owt', 0, $token); + + if($visitor_handle === false) { + return; + } + + // Try to find the public contact entry of the visitor. + $condition = ["uid" => 0, "addr" => $visitor_handle]; + $visitor = dba::selectFirst("contact", [], $condition); + + if (!DBM::is_result($visitor)) { + Probe::uri($visitor_handle, '', -1, true, true); + $visitor = dba::selectFirst("contact", [], $condition); + } + if(!DBM::is_result($visitor)) { + logger('owt: unable to finger ' . $visitor_handle, LOGGER_DEBUG); + return; + } + + // Authenticate the visitor. + $_SESSION['authenticated'] = 1; + $_SESSION['visitor_id'] = $visitor['id']; + $_SESSION['visitor_handle'] = $visitor['addr']; + $_SESSION['visitor_home'] = $visitor['url']; + + $arr = [ + 'visitor' => $visitor, + 'url' => $a->query_string, + 'session' => $_SESSION + ]; + /** + * @hooks magic_auth_success + * Called when a magic-auth was successful. + * * \e array \b visitor + * * \e string \b url + * * \e array \b session + */ + Addon::callHooks('magic_auth_success', $arr); + $a->contact = $visitor; + + info(L10n::t('OpenWebAuth: %1$s welcomes %2$s', $a->get_hostname(), $visitor['name'])); + + logger('OpenWebAuth: auth success from ' . $visitor['addr'], LOGGER_DEBUG); + } + public static function zrl($s, $force = false) { if (!strlen($s)) { @@ -1042,4 +1154,26 @@ class Profile return $uid; } + + /** + * Stip zrl parameter from a string. + * + * @param string $s The input string. + * @return string The zrl. + */ + public static function stripZrls($s) + { + return preg_replace('/[\?&]zrl=(.*?)([\?&]|$)/is', '', $s); + } + + /** + * Stip query parameter from a string. + * + * @param string $s The input string. + * @return string The query parameter. + */ + public static function stripQueryParam($s, $param) + { + return preg_replace('/[\?&]' . $param . '=(.*?)(&|$)/ism', '$2', $s); + } } diff --git a/src/Model/Verify.php b/src/Model/Verify.php new file mode 100644 index 0000000000..92dbafd3ab --- /dev/null +++ b/src/Model/Verify.php @@ -0,0 +1,73 @@ + $type, + "uid" => $uid, + "token" => $token, + "meta" => $meta, + "created" => DateTimeFormat::utcNow() + ]; + return dba::insert("verify", $fields); + } + + /** + * Get the "meta" field of an entry in the verify table. + * + * @param string $type Verify type. + * @param int $uid The user ID. + * @param string $token + * + * @return string|boolean The meta enry or false if not found. + */ + public static function getMeta($type, $uid, $token) + { + $condition = ["type" => $type, "uid" => $uid, "token" => $token]; + + $entry = dba::selectFirst("verify", ["id", "meta"], $condition); + if (DBM::is_result($entry)) { + dba::delete("verify", ["id" => $entry["id"]]); + + return $entry["meta"]; + } + return false; + } + + /** + * Purge entries of a verify-type older than interval. + * + * @param string $type Verify type. + * @param string $interval SQL compatible time interval + */ + public static function purge($type, $interval) + { + $condition = ["`type` = ? AND `created` < ?", $type, DateTimeFormat::utcNow() . " - INTERVAL " . $interval]; + dba::delete("verify", $condition); + } + +} diff --git a/src/Module/Magic.php b/src/Module/Magic.php new file mode 100644 index 0000000000..fef970da1c --- /dev/null +++ b/src/Module/Magic.php @@ -0,0 +1,121 @@ + false, 'url' => '', 'message' => '']; + logger('magic mdule: invoked', LOGGER_DEBUG); + + logger('args: ' . print_r($_REQUEST, true), LOGGER_DATA); + + $addr = ((x($_REQUEST, 'addr')) ? $_REQUEST['addr'] : ''); + $dest = ((x($_REQUEST, 'dest')) ? $_REQUEST['dest'] : ''); + $test = ((x($_REQUEST, 'test')) ? intval($_REQUEST['test']) : 0); + $owa = ((x($_REQUEST, 'owa')) ? intval($_REQUEST['owa']) : 0); + + // NOTE: I guess $dest isn't just the profile url (could be also + // other profile pages e.g. photo). We need to find a solution + // to be able to redirct to other pages than the contact profile. + $fields = ["id", "nurl", "url"]; + $condition = ["nurl" => normalise_link($dest)]; + + $contact = dba::selectFirst("contact", $fields, $condition); + + if (!DBM::is_result($contact)) { + // If we don't have a contact record, try to probe it. + /// @todo: Also check against the $addr. + Probe::uri($dest, '', -1, true, true); + $contact = dba::selectFirst("contact", $fields, $condition); + } + + if (!DBM::is_result($contact)) { + logger("No contact record found: " . print_r($_REQUEST, true), LOGGER_DEBUG); + goaway($dest); + } + + // Redirect if the contact is already authenticated on this site. + if (array_key_exists("id", $a->contact) && strpos($contact['nurl'], normalise_link(self::getApp()->get_baseurl())) !== false) { + if($test) { + $ret['success'] = true; + $ret['message'] .= 'Local site - you are already authenticated.' . EOL; + return $ret; + } + + logger("Contact is already authenticated", LOGGER_DEBUG); + goaway($dest); + } + + if (local_user()) { + $user = $a->user; + + // OpenWebAuth + if ($owa) { + // Extract the basepath + // NOTE: we need another solution because this does only work + // for friendica contacts :-/ . We should have the basepath + // of a contact also in the contact table. + $exp = explode("/profile/", $contact['url']); + $basepath = $exp[0]; + + $headers = []; + $headers['Accept'] = 'application/x-dfrn+json'; + $headers['X-Open-Web-Auth'] = random_string(); + + // Create a header that is signed with the local users private key. + $headers = HTTPSig::createSig( + '', + $headers, + $user['prvkey'], + 'acct:' . $user['nickname'] . '@' . $a->get_hostname() . ($a->path ? '/' . $a->path : ''), + false, + true, + 'sha512' + ); + + // Try to get an authentication token from the other instance. + $x = Network::curl($basepath . '/owa', false, $redirects, ['headers' => $headers]); + + if ($x['success']) { + $j = json_decode($x['body'], true); + + if ($j['success']) { + $token = ''; + if ($j['encrypted_token']) { + // The token is encrypted. If the local user is really the one the other instance + // thinks he/she is, the token can be decrypted with the local users public key. + openssl_private_decrypt(base64url_decode($j['encrypted_token']), $token, $user['prvkey']); + } else { + $token = $j['token']; + } + $x = strpbrk($dest, '?&'); + $args = (($x) ? '&owt=' . $token : '?f=&owt=' . $token); + + goaway($dest . $args); + } + } + goaway($dest); + } + } + + if($test) { + $ret['message'] = 'Not authenticated or invalid arguments' . EOL; + return $ret; + } + + goaway($dest); + } +} diff --git a/src/Module/Owa.php b/src/Module/Owa.php new file mode 100644 index 0000000000..27c863e1b7 --- /dev/null +++ b/src/Module/Owa.php @@ -0,0 +1,94 @@ + false ]; + + foreach (['REDIRECT_REMOTE_USER', 'HTTP_AUTHORIZATION'] as $head) { + if (array_key_exists($head, $_SERVER) && substr(trim($_SERVER[$head]), 0, 9) === 'Signature') { + if ($head !== 'HTTP_AUTHORIZATION') { + $_SERVER['HTTP_AUTHORIZATION'] = $_SERVER[$head]; + continue; + } + + $sigblock = HTTPSig::parseSigheader($_SERVER[$head]); + if ($sigblock) { + $keyId = $sigblock['keyId']; + + if ($keyId) { + // Try to find the public contact entry of the handle. + $handle = str_replace("acct:", "", $keyId); + $fields = ["id", "url", "addr", "pubkey"]; + $condition = ["addr" => $handle, "uid" => 0]; + + $contact = dba::selectFirst("contact", $fields, $condition); + + // Not found? Try to probe with the handle. + if(!DBM::is_result($contact)) { + Probe::uri($handle, '', -1, true, true); + $contact = dba::selectFirst("contact", $fields, $condition); + } + + if (DBM::is_result($contact)) { + // Try to verify the signed header with the public key of the contact record + // we have found. + $verified = HTTPSig::verify('', $contact['pubkey']); + + if ($verified && $verified['header_signed'] && $verified['header_valid']) { + logger('OWA header: ' . print_r($verified, true), LOGGER_DATA); + logger('OWA success: ' . $contact['addr'], LOGGER_DATA); + + $ret['success'] = true; + $token = random_string(32); + + // Store the generated token in the databe. + Verify::create('owt', 0, $token, $contact['addr']); + + $result = ''; + + // Encrypt the token with the public contacts publik key. + // Only the specific public contact will be able to encrypt it. + // At a later time, we will compare weather the token we're getting + // is really the same token we have stored in the database. + openssl_public_encrypt($token, $result, $contact['pubkey']); + $ret['encrypted_token'] = base64url_encode($result); + } else { + logger('OWA fail: ' . $contact['id'] . ' ' . $contact['addr'] . ' ' . $contact['url'], LOGGER_DEBUG); + } + } else { + logger('Contact not found: ' . $handle, LOGGER_DEBUG); + } + } + } + } + } + System::jsonExit($ret, 'application/x-dfrn+json'); + } +} diff --git a/src/Network/Probe.php b/src/Network/Probe.php index 5f665814b4..7f41b2304b 100644 --- a/src/Network/Probe.php +++ b/src/Network/Probe.php @@ -311,10 +311,11 @@ class Probe * @param string $network Test for this specific network * @param integer $uid User ID for the probe (only used for mails) * @param boolean $cache Use cached values? + * @param boolean $insert Insert the contact into the contact table. * * @return array uri data */ - public static function uri($uri, $network = "", $uid = -1, $cache = true) + public static function uri($uri, $network = "", $uid = -1, $cache = true, $insert = false) { if ($cache) { $result = Cache::get("Probe::uri:".$network.":".$uri); @@ -463,11 +464,19 @@ class Probe $condition = ['nurl' => normalise_link($data["url"]), 'self' => false, 'uid' => 0]; // "$old_fields" will return a "false" when the contact doesn't exist. - // This won't trigger an insert. This is intended, since we only need - // public contacts for everyone we store items from. - // We don't need to store every contact on the planet. + // This won't trigger an insert except $insert is set to true. + // This is intended, since we only need public contacts + // for everyone we store items from. We don't need to store + // every contact on the planet. $old_fields = dba::selectFirst('contact', $fieldnames, $condition); + // When the contact doesn't exist, the value "true" will trigger an insert + if (!$old_fields && $insert) { + $old_fields = true; + $fields['blocked'] = false; + $fields['pending'] = false; + } + $fields['name-date'] = DateTimeFormat::utcNow(); $fields['uri-date'] = DateTimeFormat::utcNow(); $fields['success_update'] = DateTimeFormat::utcNow(); diff --git a/src/Util/Crypto.php b/src/Util/Crypto.php index b2fad99700..2dc9783626 100644 --- a/src/Util/Crypto.php +++ b/src/Util/Crypto.php @@ -4,6 +4,7 @@ */ namespace Friendica\Util; +use Friendica\Core\Addon; use Friendica\Core\Config; use ASN_BASE; use ASNValue; @@ -246,4 +247,221 @@ class Crypto return $response; } + + /** + * Encrypt a string with 'aes-256-cbc' cipher method. + * + * @param string $data + * @param string $key The key used for encryption. + * @param string $iv A non-NULL Initialization Vector. + * + * @return string|boolean Encrypted string or false on failure. + */ + private static function encryptAES256CBC($data, $key, $iv) + { + return openssl_encrypt($data, 'aes-256-cbc', str_pad($key, 32, "\0"), OPENSSL_RAW_DATA, str_pad($iv, 16, "\0")); + } + + /** + * Decrypt a string with 'aes-256-cbc' cipher method. + * + * @param string $data + * @param string $key The key used for decryption. + * @param string $iv A non-NULL Initialization Vector. + * + * @return string|boolean Decrypted string or false on failure. + */ + private static function decryptAES256CBC($data, $key, $iv) + { + return openssl_decrypt($data, 'aes-256-cbc', str_pad($key, 32, "\0"), OPENSSL_RAW_DATA, str_pad($iv, 16, "\0")); + } + + /** + * Encrypt a string with 'aes-256-ctr' cipher method. + * + * @param string $data + * @param string $key The key used for encryption. + * @param string $iv A non-NULL Initialization Vector. + * + * @return string|boolean Encrypted string or false on failure. + */ + private static function encryptAES256CTR($data, $key, $iv) + { + $key = substr($key, 0, 32); + $iv = substr($iv, 0, 16); + return openssl_encrypt($data, 'aes-256-ctr', str_pad($key, 32, "\0"), OPENSSL_RAW_DATA, str_pad($iv, 16, "\0")); + } + + /** + * Decrypt a string with 'aes-256-cbc' cipher method. + * + * @param string $data + * @param string $key The key used for decryption. + * @param string $iv A non-NULL Initialization Vector. + * + * @return string|boolean Decrypted string or false on failure. + */ + private static function decryptAES256CTR($data, $key, $iv) + { + $key = substr($key, 0, 32); + $iv = substr($iv, 0, 16); + return openssl_decrypt($data, 'aes-256-ctr', str_pad($key, 32, "\0"), OPENSSL_RAW_DATA, str_pad($iv, 16, "\0")); + } + + /** + * + * @param string $data + * @param string $pubkey The public key. + * @param string $alg The algorithm used for encryption. + * + * @return array + */ + public static function encapsulate($data, $pubkey, $alg = 'aes256cbc') + { + if ($alg === 'aes256cbc') { + return self::encapsulateAes($data, $pubkey); + } + return self::encapsulateOther($data, $pubkey, $alg); + } + + /** + * + * @param type $data + * @param type $pubkey The public key. + * @param type $alg The algorithm used for encryption. + * + * @return array + */ + private static function encapsulateOther($data, $pubkey, $alg) + { + if (!$pubkey) { + logger('no key. data: '.$data); + } + $fn = 'encrypt' . strtoupper($alg); + if (method_exists(__CLASS__, $fn)) { + // A bit hesitant to use openssl_random_pseudo_bytes() as we know + // it has been historically targeted by US agencies for 'weakening'. + // It is still arguably better than trying to come up with an + // alternative cryptographically secure random generator. + // There is little point in using the optional second arg to flag the + // assurance of security since it is meaningless if the source algorithms + // have been compromised. Also none of this matters if RSA has been + // compromised by state actors and evidence is mounting that this has + // already happened. + $result = ['encrypted' => true]; + $key = openssl_random_pseudo_bytes(256); + $iv = openssl_random_pseudo_bytes(256); + $result['data'] = base64url_encode(self::$fn($data, $key, $iv), true); + + // log the offending call so we can track it down + if (!openssl_public_encrypt($key, $k, $pubkey)) { + $x = debug_backtrace(); + logger('RSA failed. ' . print_r($x[0], true)); + } + + $result['alg'] = $alg; + $result['key'] = base64url_encode($k, true); + openssl_public_encrypt($iv, $i, $pubkey); + $result['iv'] = base64url_encode($i, true); + + return $result; + } else { + $x = ['data' => $data, 'pubkey' => $pubkey, 'alg' => $alg, 'result' => $data]; + Addon::callHooks('other_encapsulate', $x); + + return $x['result']; + } + } + + /** + * + * @param string $data + * @param string $pubkey + * + * @return array + */ + private static function encapsulateAes($data, $pubkey) + { + if (!$pubkey) { + logger('aes_encapsulate: no key. data: ' . $data); + } + + $key = openssl_random_pseudo_bytes(32); + $iv = openssl_random_pseudo_bytes(16); + $result = ['encrypted' => true]; + $result['data'] = base64url_encode(AES256CBC_encrypt($data, $key, $iv), true); + + // log the offending call so we can track it down + if (!openssl_public_encrypt($key, $k, $pubkey)) { + $x = debug_backtrace(); + logger('aes_encapsulate: RSA failed. ' . print_r($x[0], true)); + } + + $result['alg'] = 'aes256cbc'; + $result['key'] = base64url_encode($k, true); + openssl_public_encrypt($iv, $i, $pubkey); + $result['iv'] = base64url_encode($i, true); + + return $result; + } + + /** + * + * @param string $data + * @param string $prvkey The private key used for decryption. + * + * @return string|boolean The decrypted string or false on failure. + */ + public static function unencapsulate($data, $prvkey) + { + if (!$data) { + return; + } + + $alg = ((array_key_exists('alg', $data)) ? $data['alg'] : 'aes256cbc'); + if ($alg === 'aes256cbc') { + return self::encapsulateAes($data, $prvkey); + } + return self::encapsulateOther($data, $prvkey, $alg); + } + + /** + * + * @param string $data + * @param string $prvkey The private key used for decryption. + * @param string $alg + * + * @return string|boolean The decrypted string or false on failure. + */ + private static function unencapsulateOther($data, $prvkey, $alg) + { + $fn = 'decrypt' . strtoupper($alg); + + if (method_exists(__CLASS__, $fn)) { + openssl_private_decrypt(base64url_decode($data['key']), $k, $prvkey); + openssl_private_decrypt(base64url_decode($data['iv']), $i, $prvkey); + + return self::$fn(base64url_decode($data['data']), $k, $i); + } else { + $x = ['data' => $data, 'prvkey' => $prvkey, 'alg' => $alg, 'result' => $data]; + Addon::callHooks('other_unencapsulate', $x); + + return $x['result']; + } + } + + /** + * + * @param array $data + * @param string $prvkey The private key used for decryption. + * + * @return string|boolean The decrypted string or false on failure. + */ + private static function unencapsulateAes($data, $prvkey) + { + openssl_private_decrypt(base64url_decode($data['key']), $k, $prvkey); + openssl_private_decrypt(base64url_decode($data['iv']), $i, $prvkey); + + return self::decryptAES256CBC(base64url_decode($data['data']), $k, $i); + } } diff --git a/src/Util/HTTPHeaders.php b/src/Util/HTTPHeaders.php new file mode 100644 index 0000000000..a6c270d136 --- /dev/null +++ b/src/Util/HTTPHeaders.php @@ -0,0 +1,59 @@ +in_progress['k']) { + $this->in_progress['v'] .= ' ' . ltrim($line); + continue; + } + } else { + if ($this->in_progress['k']) { + $this->parsed[] = [$this->in_progress['k'] => $this->in_progress['v']]; + $this->in_progress = []; + } + + $this->in_progress['k'] = strtolower(substr($line, 0, strpos($line, ':'))); + $this->in_progress['v'] = ltrim(substr($line, strpos($line, ':') + 1)); + } + } + + if ($this->in_progress['k']) { + $this->parsed[] = [$this->in_progress['k'] => $this->in_progress['v']]; + $this->in_progress = []; + } + } + } + + function fetch() + { + return $this->parsed; + } + + function fetcharr() + { + $ret = []; + + if ($this->parsed) { + foreach ($this->parsed as $x) { + foreach ($x as $y => $z) { + $ret[$y] = $z; + } + } + } + return $ret; + } +} diff --git a/src/Util/HTTPSig.php b/src/Util/HTTPSig.php new file mode 100644 index 0000000000..a7c9f23367 --- /dev/null +++ b/src/Util/HTTPSig.php @@ -0,0 +1,352 @@ + '', + 'header_signed' => false, + 'header_valid' => false, + 'content_signed' => false, + 'content_valid' => false + ]; + + // Decide if $data arrived via controller submission or curl. + if (is_array($data) && $data['header']) { + if (!$data['success']) { + return $result; + } + + $h = new HTTPHeaders($data['header']); + $headers = $h->fetcharr(); + $body = $data['body']; + } else { + $headers = []; + $headers['(request-target)'] = strtolower($_SERVER['REQUEST_METHOD']).' '.$_SERVER['REQUEST_URI']; + + foreach ($_SERVER as $k => $v) { + if (strpos($k, 'HTTP_') === 0) { + $field = str_replace('_', '-', strtolower(substr($k, 5))); + $headers[$field] = $v; + } + } + } + + $sig_block = null; + + if (array_key_exists('signature', $headers)) { + $sig_block = self::parseSigheader($headers['signature']); + } elseif (array_key_exists('authorization', $headers)) { + $sig_block = self::parseSigheader($headers['authorization']); + } + + if (!$sig_block) { + logger('no signature provided.'); + return $result; + } + + // Warning: This log statement includes binary data + // logger('sig_block: ' . print_r($sig_block,true), LOGGER_DATA); + + $result['header_signed'] = true; + + $signed_headers = $sig_block['headers']; + if (!$signed_headers) { + $signed_headers = ['date']; + } + + $signed_data = ''; + foreach ($signed_headers as $h) { + if (array_key_exists($h, $headers)) { + $signed_data .= $h . ': ' . $headers[$h] . "\n"; + } + if (strpos($h, '.')) { + $spoofable = true; + } + } + + $signed_data = rtrim($signed_data, "\n"); + + $algorithm = null; + if ($sig_block['algorithm'] === 'rsa-sha256') { + $algorithm = 'sha256'; + } + if ($sig_block['algorithm'] === 'rsa-sha512') { + $algorithm = 'sha512'; + } + + if ($key && function_exists($key)) { /// @todo What function do we check for - maybe we check now for a method !!! + $result['signer'] = $sig_block['keyId']; + $key = $key($sig_block['keyId']); + } + + if (!$key) { + return $result; + } + + $x = Crypto::rsaVerify($signed_data, $sig_block['signature'], $key, $algorithm); + + logger('verified: ' . $x, LOGGER_DEBUG); + + if (!$x) { + return $result; + } + + if (!$spoofable) { + $result['header_valid'] = true; + } + + if (in_array('digest', $signed_headers)) { + $result['content_signed'] = true; + $digest = explode('=', $headers['digest']); + + if ($digest[0] === 'SHA-256') { + $hashalg = 'sha256'; + } + if ($digest[0] === 'SHA-512') { + $hashalg = 'sha512'; + } + + // The explode operation will have stripped the '=' padding, so compare against unpadded base64. + if (rtrim(base64_encode(hash($hashalg, $body, true)), '=') === $digest[1]) { + $result['content_valid'] = true; + } + } + + logger('Content_Valid: ' . $result['content_valid']); + + return $result; + } + + /** + * @brief + * + * @param string $request + * @param array $head + * @param string $prvkey + * @param string $keyid (optional, default 'Key') + * @param boolean $send_headers (optional, default false) + * If set send a HTTP header + * @param boolean $auth (optional, default false) + * @param string $alg (optional, default 'sha256') + * @param string $crypt_key (optional, default null) + * @param string $crypt_algo (optional, default 'aes256ctr') + * + * @return array + */ + public static function createSig($request, $head, $prvkey, $keyid = 'Key', $send_headers = false, $auth = false, $alg = 'sha256', $crypt_key = null, $crypt_algo = 'aes256ctr') + { + $return_headers = []; + + if ($alg === 'sha256') { + $algorithm = 'rsa-sha256'; + } + + if ($alg === 'sha512') { + $algorithm = 'rsa-sha512'; + } + + $x = self::sign($request, $head, $prvkey, $alg); + + $headerval = 'keyId="' . $keyid . '",algorithm="' . $algorithm + . '",headers="' . $x['headers'] . '",signature="' . $x['signature'] . '"'; + + if ($crypt_key) { + $x = Crypto::encapsulate($headerval, $crypt_key, $crypt_algo); + $headerval = 'iv="' . $x['iv'] . '",key="' . $x['key'] . '",alg="' . $x['alg'] . '",data="' . $x['data'] . '"'; + } + + if ($auth) { + $sighead = 'Authorization: Signature ' . $headerval; + } else { + $sighead = 'Signature: ' . $headerval; + } + + if ($head) { + foreach ($head as $k => $v) { + if ($send_headers) { + header($k . ': ' . $v); + } else { + $return_headers[] = $k . ': ' . $v; + } + } + } + + if ($send_headers) { + header($sighead); + } else { + $return_headers[] = $sighead; + } + + return $return_headers; + } + + /** + * @brief + * + * @param string $request + * @param array $head + * @param string $prvkey + * @param string $alg (optional) default 'sha256' + * + * @return array + */ + private static function sign($request, $head, $prvkey, $alg = 'sha256') + { + $ret = []; + $headers = ''; + $fields = ''; + + if ($request) { + $headers = '(request-target)' . ': ' . trim($request) . "\n"; + $fields = '(request-target)'; + } + + if ($head) { + foreach ($head as $k => $v) { + $headers .= strtolower($k) . ': ' . trim($v) . "\n"; + if ($fields) { + $fields .= ' '; + } + $fields .= strtolower($k); + } + // strip the trailing linefeed + $headers = rtrim($headers, "\n"); + } + + $sig = base64_encode(Crypto::rsaSign($headers, $prvkey, $alg)); + + $ret['headers'] = $fields; + $ret['signature'] = $sig; + + return $ret; + } + + /** + * @brief + * + * @param string $header + * @return array associate array with + * - \e string \b keyID + * - \e string \b algorithm + * - \e array \b headers + * - \e string \b signature + */ + public static function parseSigheader($header) + { + $ret = []; + $matches = []; + + // if the header is encrypted, decrypt with (default) site private key and continue + if (preg_match('/iv="(.*?)"/ism', $header, $matches)) { + $header = self::decryptSigheader($header); + } + + if (preg_match('/keyId="(.*?)"/ism', $header, $matches)) { + $ret['keyId'] = $matches[1]; + } + + if (preg_match('/algorithm="(.*?)"/ism', $header, $matches)) { + $ret['algorithm'] = $matches[1]; + } + + if (preg_match('/headers="(.*?)"/ism', $header, $matches)) { + $ret['headers'] = explode(' ', $matches[1]); + } + + if (preg_match('/signature="(.*?)"/ism', $header, $matches)) { + $ret['signature'] = base64_decode(preg_replace('/\s+/', '', $matches[1])); + } + + if (($ret['signature']) && ($ret['algorithm']) && (!$ret['headers'])) { + $ret['headers'] = ['date']; + } + + return $ret; + } + + /** + * @brief + * + * @param string $header + * @param string $prvkey (optional), if not set use site private key + * + * @return array|string associative array, empty string if failue + * - \e string \b iv + * - \e string \b key + * - \e string \b alg + * - \e string \b data + */ + private static function decryptSigheader($header, $prvkey = null) + { + $iv = $key = $alg = $data = null; + + if (!$prvkey) { + $prvkey = Config::get('system', 'prvkey'); + } + + $matches = []; + + if (preg_match('/iv="(.*?)"/ism', $header, $matches)) { + $iv = $matches[1]; + } + + if (preg_match('/key="(.*?)"/ism', $header, $matches)) { + $key = $matches[1]; + } + + if (preg_match('/alg="(.*?)"/ism', $header, $matches)) { + $alg = $matches[1]; + } + + if (preg_match('/data="(.*?)"/ism', $header, $matches)) { + $data = $matches[1]; + } + + if ($iv && $key && $alg && $data) { + return Crypto::unencapsulate(['iv' => $iv, 'key' => $key, 'alg' => $alg, 'data' => $data], $prvkey); + } + + return ''; + } +} diff --git a/view/templates/xrd_person.tpl b/view/templates/xrd_person.tpl index 360489b87d..aa402b1a8f 100644 --- a/view/templates/xrd_person.tpl +++ b/view/templates/xrd_person.tpl @@ -33,4 +33,7 @@ template="{{$subscribe}}" /> +