diff --git a/boot.php b/boot.php
index 79ec53abf..46bb2a3f8 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 b186c0c0a..d084ba519 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 openwebauth-token
+--
+CREATE TABLE IF NOT EXISTS `openwebauth-token` (
+ `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 OpenWebAuth token to verify contacts';
+
--
-- TABLE worker-ipc
--
diff --git a/doc/Addons.md b/doc/Addons.md
index 062f90795..710d10cfd 100644
--- a/doc/Addons.md
+++ b/doc/Addons.md
@@ -357,6 +357,12 @@ 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
+
Current JavaScript hooks
-------------
@@ -557,6 +563,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 aeda99982..f65867feb 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::openWebAuthInit($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 bbfd7ce64..d251d41fe 100644
--- a/mod/xrd.php
+++ b/mod/xrd.php
@@ -66,20 +66,23 @@ function xrd_json($a, $uri, $alias, $profile_url, $r)
header("Content-type: application/json; charset=utf-8");
$json = ['subject' => $uri,
- 'aliases' => [$alias, $profile_url],
- 'links' => [['rel' => NAMESPACE_DFRN, 'href' => $profile_url],
- ['rel' => NAMESPACE_FEED, 'type' => 'application/atom+xml', 'href' => System::baseUrl().'/dfrn_poll/'.$r['nickname']],
- ['rel' => 'http://webfinger.net/rel/profile-page', 'type' => 'text/html', 'href' => $profile_url],
- ['rel' => 'http://microformats.org/profile/hcard', 'type' => 'text/html', 'href' => System::baseUrl().'/hcard/'.$r['nickname']],
- ['rel' => NAMESPACE_POCO, 'href' => System::baseUrl().'/poco/'.$r['nickname']],
- ['rel' => 'http://webfinger.net/rel/avatar', 'type' => 'image/jpeg', 'href' => System::baseUrl().'/photo/profile/'.$r['uid'].'.jpg'],
- ['rel' => 'http://joindiaspora.com/seed_location', 'type' => 'text/html', 'href' => System::baseUrl()],
- ['rel' => 'salmon', 'href' => System::baseUrl().'/salmon/'.$r['nickname']],
- ['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]
- ]];
+ 'aliases' => [$alias, $profile_url],
+ 'links' => [
+ ['rel' => NAMESPACE_DFRN, 'href' => $profile_url],
+ ['rel' => NAMESPACE_FEED, 'type' => 'application/atom+xml', 'href' => System::baseUrl().'/dfrn_poll/'.$r['nickname']],
+ ['rel' => 'http://webfinger.net/rel/profile-page', 'type' => 'text/html', 'href' => $profile_url],
+ ['rel' => 'http://microformats.org/profile/hcard', 'type' => 'text/html', 'href' => System::baseUrl().'/hcard/'.$r['nickname']],
+ ['rel' => NAMESPACE_POCO, 'href' => System::baseUrl().'/poco/'.$r['nickname']],
+ ['rel' => 'http://webfinger.net/rel/avatar', 'type' => 'image/jpeg', 'href' => System::baseUrl().'/photo/profile/'.$r['uid'].'.jpg'],
+ ['rel' => 'http://joindiaspora.com/seed_location', 'type' => 'text/html', 'href' => System::baseUrl()],
+ ['rel' => 'salmon', 'href' => System::baseUrl().'/salmon/'.$r['nickname']],
+ ['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' => 'http://purl.org/openwebauth/v1', 'type' => 'application/x-dfrn+json', 'href' => System::baseUrl().'/owa']
+ ]
+ ];
echo json_encode($json);
killme();
}
@@ -102,10 +105,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 1db417eb8..ded781da8 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 d4419553c..0d7ba49e4 100644
--- a/src/Database/DBStructure.php
+++ b/src/Database/DBStructure.php
@@ -1818,6 +1818,20 @@ class DBStructure
"PRIMARY" => ["uid", "iid"],
]
];
+ $database["openwebauth-token"] = [
+ "comment" => "Store OpenWebAuth 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/OpenWebAuthToken.php b/src/Model/OpenWebAuthToken.php
new file mode 100644
index 000000000..5c405b27d
--- /dev/null
+++ b/src/Model/OpenWebAuthToken.php
@@ -0,0 +1,73 @@
+ $type,
+ "uid" => $uid,
+ "token" => $token,
+ "meta" => $meta,
+ "created" => DateTimeFormat::utcNow()
+ ];
+ return dba::insert("openwebauth-token", $fields);
+ }
+
+ /**
+ * Get the "meta" field of an entry in the openwebauth-token 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("openwebauth-token", ["id", "meta"], $condition);
+ if (DBM::is_result($entry)) {
+ dba::delete("openwebauth-token", ["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("openwebauth-token", $condition);
+ }
+
+}
diff --git a/src/Model/Profile.php b/src/Model/Profile.php
index 39a89694a..0bb18e146 100644
--- a/src/Model/Profile.php
+++ b/src/Model/Profile.php
@@ -17,6 +17,7 @@ use Friendica\Core\System;
use Friendica\Core\Worker;
use Friendica\Database\DBM;
use Friendica\Model\Contact;
+use Friendica\Model\OpenWebAuthToken;
use Friendica\Protocol\Diaspora;
use Friendica\Util\DateTimeFormat;
use Friendica\Util\Network;
@@ -978,27 +979,128 @@ 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.
+ *
+ * Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/channel.php
+ *
+ * @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.
+ $cid = Contact::getIdForURL($my_url);
+ if (!$cid) {
+ logger('No contact record found for ' . $my_url, LOGGER_DEBUG);
+ return;
+ }
+
+ $contact = dba::selectFirst('contact',['id', 'url'], ['id' => $cid]);
+
+ 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.
+ *
+ * Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/zid.php
+ *
+ * @param string $token
+ */
+ public static function openWebAuthInit($token)
+ {
+ $a = get_app();
+
+ // Clean old OpenWebAuthToken entries.
+ OpenWebAuthToken::purge('owt', '3 MINUTE');
+
+ // Check if the token we got is the same one
+ // we have stored in the database.
+ $visitor_handle = OpenWebAuthToken::getMeta('owt', 0, $token);
+
+ if($visitor_handle === false) {
+ return;
+ }
+
+ // Try to find the public contact entry of the visitor.
+ $cid = Contact::getIdForURL($visitor_handle);
+ if(!$cid) {
+ logger('owt: unable to finger ' . $visitor_handle, LOGGER_DEBUG);
+ return;
+ }
+
+ $visitor = dba::selectFirst('contact', [], ['id' => $cid]);
+
+ // 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
+ ];
+ /**
+ * @hooks magic_auth_success
+ * Called when a magic-auth was successful.
+ * * \e array \b visitor
+ * * \e string \b url
+ */
+ Addon::callHooks('magic_auth_success', $arr);
+
+ $a->contact = $arr['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 +1144,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/Module/Magic.php b/src/Module/Magic.php
new file mode 100644
index 000000000..ce41f228d
--- /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.
+ $cid = Contact::getIdForURL($dest);
+
+ if (!$cid && !empty($addr)) {
+ $cid = Contact::getIdForURL($addr);
+ }
+
+ if (!$cid) {
+ logger('No contact record found: ' . print_r($_REQUEST, true), LOGGER_DEBUG);
+ goaway($dest);
+ }
+
+ $contact = dba::selectFirst('contact', ['id', 'nurl', 'url'], ['id' => $cid]);
+
+ // 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 = HTTPSignature::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 000000000..306c525c0
--- /dev/null
+++ b/src/Module/Owa.php
@@ -0,0 +1,91 @@
+ 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 = HTTPSignature::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);
+
+ $cid = Contact::getIdForURL($handle);
+ $fields = ['id', 'url', 'addr', 'pubkey'];
+ $condition = ['id' => $cid];
+
+ $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 = HTTPSignature::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.
+ OpenWebAuthToken::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/Util/Crypto.php b/src/Util/Crypto.php
index b2fad9970..6a49626bd 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,232 @@ class Crypto
return $response;
}
+
+ /**
+ * Encrypt a string with 'aes-256-cbc' cipher method.
+ *
+ * Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/crypto.php
+ *
+ * @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.
+ *
+ * Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/crypto.php
+ *
+ * @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.
+ *
+ * Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/crypto.php
+ *
+ * @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-ctr' cipher method.
+ *
+ * Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/crypto.php
+ *
+ * @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"));
+ }
+
+ /**
+ *
+ * Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/crypto.php
+ *
+ * @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);
+ }
+
+ /**
+ *
+ * Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/crypto.php
+ *
+ * @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)) {
+ $result = ['encrypted' => true];
+ $key = random_bytes(256);
+ $iv = random_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'];
+ }
+ }
+
+ /**
+ *
+ * Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/crypto.php
+ *
+ * @param string $data
+ * @param string $pubkey
+ *
+ * @return array
+ */
+ private static function encapsulateAes($data, $pubkey)
+ {
+ if (!$pubkey) {
+ logger('aes_encapsulate: no key. data: ' . $data);
+ }
+
+ $key = random_bytes(32);
+ $iv = random_bytes(16);
+ $result = ['encrypted' => true];
+ $result['data'] = base64url_encode(self::encryptAES256CBC($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;
+ }
+
+ /**
+ *
+ * Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/crypto.php
+ *
+ * @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);
+ }
+
+ /**
+ *
+ * Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/crypto.php
+ *
+ * @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'];
+ }
+ }
+
+ /**
+ *
+ * Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/crypto.php
+ *
+ * @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 000000000..9b0c4529d
--- /dev/null
+++ b/src/Util/HTTPHeaders.php
@@ -0,0 +1,48 @@
+in_progress['k'])) {
+ $this->in_progress['v'] .= ' ' . ltrim($line);
+ continue;
+ }
+ } else {
+ if (!empty($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 (!empty($this->in_progress['k'])) {
+ $this->parsed[$this->in_progress['k']] = $this->in_progress['v'];
+ $this->in_progress = [];
+ }
+ }
+ }
+
+ function fetch()
+ {
+ return $this->parsed;
+ }
+}
diff --git a/src/Util/HTTPSignature.php b/src/Util/HTTPSignature.php
new file mode 100644
index 000000000..a91b6b37e
--- /dev/null
+++ b/src/Util/HTTPSignature.php
@@ -0,0 +1,409 @@
+ '',
+ '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->fetch();
+ $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)) {
+ $result['signer'] = $sig_block['keyId'];
+ $key = $key($sig_block['keyId']);
+ }
+
+ // We don't use Activity Pub at the moment.
+// if (!$key) {
+// $result['signer'] = $sig_block['keyId'];
+// $key = self::getActivitypubKey($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;
+ }
+
+ /**
+ * Fetch the public key for Activity Pub contact.
+ *
+ * @param string|int The identifier (contact addr or contact ID).
+ * @return string|boolean The public key or false on failure.
+ */
+ private static function getActivitypubKey($id)
+ {
+ if (strpos($id, 'acct:') === 0) {
+ $contact = dba::selectFirst('contact', ['pubkey'], ['uid' => 0, 'addr' => str_replace('acct:', '', $id)]);
+ } else {
+ $contact = dba::selectFirst('contact', ['pubkey'], ['id' => $id, 'network' => 'activitypub']);
+ }
+
+ if (DBM::is_result($contact)) {
+ return $contact['pubkey'];
+ }
+
+ if(function_exists('as_fetch')) {
+ $r = as_fetch($id);
+ }
+
+ if ($r) {
+ $j = json_decode($r, true);
+
+ if (array_key_exists('publicKey', $j) && array_key_exists('publicKeyPem', $j['publicKey'])) {
+ if ((array_key_exists('id', $j['publicKey']) && $j['publicKey']['id'] !== $id) && $j['id'] !== $id) {
+ return false;
+ }
+
+ return $j['publicKey']['publicKeyPem'];
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @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) {
+ // This is for ActivityPub implementation.
+ // Since the Activity Pub implementation isn't
+ // ready at the moment, we comment it out.
+ // header($k . ': ' . $v);
+ } else {
+ $return_headers[] = $k . ': ' . $v;
+ }
+ }
+ }
+
+ if ($send_headers) {
+ // This is for ActivityPub implementation.
+ // Since the Activity Pub implementation isn't
+ // ready at the moment, we comment it out.
+ // 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 360489b87..aa402b1a8 100644
--- a/view/templates/xrd_person.tpl
+++ b/view/templates/xrd_person.tpl
@@ -33,4 +33,7 @@
template="{{$subscribe}}" />
+