Remove mod/subthread file
[friendica.git/.git] / mod / dfrn_confirm.php
1 <?php
2 /**
3  * @copyright Copyright (C) 2020, Friendica
4  *
5  * @license GNU AGPL version 3 or any later version
6  *
7  * This program is free software: you can redistribute it and/or modify
8  * it under the terms of the GNU Affero General Public License as
9  * published by the Free Software Foundation, either version 3 of the
10  * License, or (at your option) any later version.
11  *
12  * This program is distributed in the hope that it will be useful,
13  * but WITHOUT ANY WARRANTY; without even the implied warranty of
14  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15  * GNU Affero General Public License for more details.
16  *
17  * You should have received a copy of the GNU Affero General Public License
18  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
19  *
20  * Friendship acceptance for DFRN contacts
21  *
22  * There are two possible entry points and three scenarios.
23  *
24  *   1. A form was submitted by our user approving a friendship that originated elsewhere.
25  *      This may also be called from dfrn_request to automatically approve a friendship.
26  *
27  *   2. We may be the target or other side of the conversation to scenario 1, and will
28  *      interact with that process on our own user's behalf.
29  *
30  *  @see PDF with dfrn specs: https://github.com/friendica/friendica/blob/stable/spec/dfrn2.pdf
31  *    You also find a graphic which describes the confirmation process at
32  *    https://github.com/friendica/friendica/blob/stable/spec/dfrn2_contact_confirmation.png
33  */
34
35 use Friendica\App;
36 use Friendica\Core\Logger;
37 use Friendica\Core\Protocol;
38 use Friendica\Core\System;
39 use Friendica\Database\DBA;
40 use Friendica\DI;
41 use Friendica\Model\Contact;
42 use Friendica\Model\Group;
43 use Friendica\Model\Notification;
44 use Friendica\Model\User;
45 use Friendica\Protocol\Activity;
46 use Friendica\Util\Crypto;
47 use Friendica\Util\DateTimeFormat;
48 use Friendica\Util\Strings;
49 use Friendica\Util\XML;
50
51 function dfrn_confirm_post(App $a, $handsfree = null)
52 {
53         $node = null;
54         if (is_array($handsfree)) {
55                 /*
56                  * We were called directly from dfrn_request due to automatic friend acceptance.
57                  * Any $_POST parameters we may require are supplied in the $handsfree array.
58                  *
59                  */
60                 $node = $handsfree['node'];
61                 $a->interactive = false; // notice() becomes a no-op since nobody is there to see it
62         } elseif ($a->argc > 1) {
63                 $node = $a->argv[1];
64         }
65
66         /*
67          * Main entry point. Scenario 1. Our user received a friend request notification (perhaps
68          * from another site) and clicked 'Approve'.
69          * $POST['source_url'] is not set. If it is, it indicates Scenario 2.
70          *
71          * We may also have been called directly from dfrn_request ($handsfree != null) due to
72          * this being a page type which supports automatic friend acceptance. That is also Scenario 1
73          * since we are operating on behalf of our registered user to approve a friendship.
74          */
75         if (empty($_POST['source_url'])) {
76                 $uid = ($handsfree['uid'] ?? 0) ?: local_user();
77                 if (!$uid) {
78                         notice(DI::l10n()->t('Permission denied.'));
79                         return;
80                 }
81
82                 $user = DBA::selectFirst('user', [], ['uid' => $uid]);
83                 if (!DBA::isResult($user)) {
84                         notice(DI::l10n()->t('Profile not found.'));
85                         return;
86                 }
87
88                 // These data elements may come from either the friend request notification form or $handsfree array.
89                 if (is_array($handsfree)) {
90                         Logger::log('Confirm in handsfree mode');
91                         $dfrn_id  = $handsfree['dfrn_id'];
92                         $intro_id = $handsfree['intro_id'];
93                         $duplex   = $handsfree['duplex'];
94                         $cid      = 0;
95                         $hidden   = intval($handsfree['hidden'] ?? 0);
96                 } else {
97                         $dfrn_id  = Strings::escapeTags(trim($_POST['dfrn_id'] ?? ''));
98                         $intro_id = intval($_POST['intro_id']   ?? 0);
99                         $duplex   = intval($_POST['duplex']     ?? 0);
100                         $cid      = intval($_POST['contact_id'] ?? 0);
101                         $hidden   = intval($_POST['hidden']     ?? 0);
102                 }
103
104                 /*
105                  * Ensure that dfrn_id has precedence when we go to find the contact record.
106                  * We only want to search based on contact id if there is no dfrn_id,
107                  * e.g. for OStatus network followers.
108                  */
109                 if (strlen($dfrn_id)) {
110                         $cid = 0;
111                 }
112
113                 Logger::log('Confirming request for dfrn_id (issued) ' . $dfrn_id);
114                 if ($cid) {
115                         Logger::log('Confirming follower with contact_id: ' . $cid);
116                 }
117
118                 /*
119                  * The other person will have been issued an ID when they first requested friendship.
120                  * Locate their record. At this time, their record will have both pending and blocked set to 1.
121                  * There won't be any dfrn_id if this is a network follower, so use the contact_id instead.
122                  */
123                 $r = q("SELECT *
124                         FROM `contact`
125                         WHERE (
126                                 (`issued-id` != '' AND `issued-id` = '%s')
127                                 OR
128                                 (`id` = %d AND `id` != 0)
129                         )
130                         AND `uid` = %d
131                         AND `duplex` = 0
132                         LIMIT 1",
133                         DBA::escape($dfrn_id),
134                         intval($cid),
135                         intval($uid)
136                 );
137                 if (!DBA::isResult($r)) {
138                         Logger::log('Contact not found in DB.');
139                         notice(DI::l10n()->t('Contact not found.'));
140                         notice(DI::l10n()->t('This may occasionally happen if contact was requested by both persons and it has already been approved.'));
141                         return;
142                 }
143
144                 $contact = $r[0];
145
146                 $contact_id   = $contact['id'];
147                 $relation     = $contact['rel'];
148                 $site_pubkey  = $contact['site-pubkey'];
149                 $dfrn_confirm = $contact['confirm'];
150                 $aes_allow    = $contact['aes_allow'];
151                 $protocol     = $contact['network'];
152
153                 /*
154                  * Generate a key pair for all further communications with this person.
155                  * We have a keypair for every contact, and a site key for unknown people.
156                  * This provides a means to carry on relationships with other people if
157                  * any single key is compromised. It is a robust key. We're much more
158                  * worried about key leakage than anybody cracking it.
159                  */
160                 $res = Crypto::newKeypair(4096);
161
162                 $private_key = $res['prvkey'];
163                 $public_key  = $res['pubkey'];
164
165                 // Save the private key. Send them the public key.
166                 $fields = ['prvkey' => $private_key, 'protocol' => Protocol::DFRN];
167                 DBA::update('contact', $fields, ['id' => $contact_id]);
168
169                 $params = [];
170
171                 /*
172                  * Per the DFRN protocol, we will verify both ends by encrypting the dfrn_id with our
173                  * site private key (person on the other end can decrypt it with our site public key).
174                  * Then encrypt our profile URL with the other person's site public key. They can decrypt
175                  * it with their site private key. If the decryption on the other end fails for either
176                  * item, it indicates tampering or key failure on at least one site and we will not be
177                  * able to provide a secure communication pathway.
178                  *
179                  * If other site is willing to accept full encryption, (aes_allow is 1 AND we have php5.3
180                  * or later) then we encrypt the personal public key we send them using AES-256-CBC and a
181                  * random key which is encrypted with their site public key.
182                  */
183
184                 $src_aes_key = openssl_random_pseudo_bytes(64);
185
186                 $result = '';
187                 openssl_private_encrypt($dfrn_id, $result, $user['prvkey']);
188
189                 $params['dfrn_id'] = bin2hex($result);
190                 $params['public_key'] = $public_key;
191
192                 $my_url = DI::baseUrl() . '/profile/' . $user['nickname'];
193
194                 openssl_public_encrypt($my_url, $params['source_url'], $site_pubkey);
195                 $params['source_url'] = bin2hex($params['source_url']);
196
197                 if ($aes_allow && function_exists('openssl_encrypt')) {
198                         openssl_public_encrypt($src_aes_key, $params['aes_key'], $site_pubkey);
199                         $params['aes_key'] = bin2hex($params['aes_key']);
200                         $params['public_key'] = bin2hex(openssl_encrypt($public_key, 'AES-256-CBC', $src_aes_key));
201                 }
202
203                 $params['dfrn_version'] = DFRN_PROTOCOL_VERSION;
204                 if ($duplex == 1) {
205                         $params['duplex'] = 1;
206                 }
207
208                 if ($user['page-flags'] == User::PAGE_FLAGS_COMMUNITY) {
209                         $params['page'] = 1;
210                 }
211
212                 if ($user['page-flags'] == User::PAGE_FLAGS_PRVGROUP) {
213                         $params['page'] = 2;
214                 }
215
216                 Logger::debug('Confirm: posting data', ['confirm'  => $dfrn_confirm, 'parameter' => $params]);
217
218                 /*
219                  *
220                  * POST all this stuff to the other site.
221                  * Temporarily raise the network timeout to 120 seconds because the default 60
222                  * doesn't always give the other side quite enough time to decrypt everything.
223                  *
224                  */
225
226                 $res = DI::httpRequest()->post($dfrn_confirm, $params, [], 120)->getBody();
227
228                 Logger::log(' Confirm: received data: ' . $res, Logger::DATA);
229
230                 // Now figure out what they responded. Try to be robust if the remote site is
231                 // having difficulty and throwing up errors of some kind.
232
233                 $leading_junk = substr($res, 0, strpos($res, '<?xml'));
234
235                 $res = substr($res, strpos($res, '<?xml'));
236                 if (!strlen($res)) {
237                         // No XML at all, this exchange is messed up really bad.
238                         // We shouldn't proceed, because the xml parser might choke,
239                         // and $status is going to be zero, which indicates success.
240                         // We can hardly call this a success.
241                         notice(DI::l10n()->t('Response from remote site was not understood.'));
242                         return;
243                 }
244
245                 if (strlen($leading_junk) && DI::config()->get('system', 'debugging')) {
246                         // This might be more common. Mixed error text and some XML.
247                         // If we're configured for debugging, show the text. Proceed in either case.
248                         notice(DI::l10n()->t('Unexpected response from remote site: ') . $leading_junk);
249                 }
250
251                 if (stristr($res, "<status") === false) {
252                         // wrong xml! stop here!
253                         Logger::log('Unexpected response posting to ' . $dfrn_confirm);
254                         notice(DI::l10n()->t('Unexpected response from remote site: ') . EOL . htmlspecialchars($res));
255                         return;
256                 }
257
258                 $xml = XML::parseString($res);
259                 $status = (int) $xml->status;
260                 $message = XML::unescape($xml->message);   // human readable text of what may have gone wrong.
261                 switch ($status) {
262                         case 0:
263                                 info(DI::l10n()->t("Confirmation completed successfully."));
264                                 break;
265                         case 1:
266                                 // birthday paradox - generate new dfrn-id and fall through.
267                                 $new_dfrn_id = Strings::getRandomHex();
268                                 q("UPDATE contact SET `issued-id` = '%s' WHERE `id` = %d AND `uid` = %d",
269                                         DBA::escape($new_dfrn_id),
270                                         intval($contact_id),
271                                         intval($uid)
272                                 );
273
274                         case 2:
275                                 notice(DI::l10n()->t("Temporary failure. Please wait and try again."));
276                                 break;
277                         case 3:
278                                 notice(DI::l10n()->t("Introduction failed or was revoked."));
279                                 break;
280                 }
281
282                 if (strlen($message)) {
283                         notice(DI::l10n()->t('Remote site reported: ') . $message);
284                 }
285
286                 if (($status == 0) && $intro_id) {
287                         $intro = DBA::selectFirst('intro', ['note'], ['id' => $intro_id]);
288                         if (DBA::isResult($intro)) {
289                                 DBA::update('contact', ['reason' => $intro['note']], ['id' => $contact_id]);
290                         }
291
292                         // Success. Delete the notification.
293                         DBA::delete('intro', ['id' => $intro_id]);
294                 }
295
296                 if ($status != 0) {
297                         return;
298                 }
299
300                 /*
301                  * We have now established a relationship with the other site.
302                  * Let's make our own personal copy of their profile photo so we don't have
303                  * to always load it from their site.
304                  *
305                  * We will also update the contact record with the nature and scope of the relationship.
306                  */
307                 Contact::updateAvatar($contact_id, $contact['photo']);
308
309                 Logger::log('dfrn_confirm: confirm - imported photos');
310
311                 $new_relation = Contact::FOLLOWER;
312
313                 if (($relation == Contact::SHARING) || ($duplex)) {
314                         $new_relation = Contact::FRIEND;
315                 }
316
317                 if (($relation == Contact::SHARING) && ($duplex)) {
318                         $duplex = 0;
319                 }
320
321                 $r = q("UPDATE `contact` SET `rel` = %d,
322                         `name-date` = '%s',
323                         `uri-date` = '%s',
324                         `blocked` = 0,
325                         `pending` = 0,
326                         `duplex` = %d,
327                         `hidden` = %d,
328                         `network` = '%s' WHERE `id` = %d
329                 ",
330                         intval($new_relation),
331                         DBA::escape(DateTimeFormat::utcNow()),
332                         DBA::escape(DateTimeFormat::utcNow()),
333                         intval($duplex),
334                         intval($hidden),
335                         DBA::escape(Protocol::DFRN),
336                         intval($contact_id)
337                 );
338
339                 // reload contact info
340                 $contact = DBA::selectFirst('contact', [], ['id' => $contact_id]);
341
342                 Group::addMember(User::getDefaultGroup($uid, $contact["network"]), $contact['id']);
343
344                 // Let's send our user to the contact editor in case they want to
345                 // do anything special with this new friend.
346                 if ($handsfree === null) {
347                         DI::baseUrl()->redirect('contact/' . intval($contact_id));
348                 } else {
349                         return;
350                 }
351                 //NOTREACHED
352         }
353
354         /*
355          * End of Scenario 1. [Local confirmation of remote friend request].
356          *
357          * Begin Scenario 2. This is the remote response to the above scenario.
358          * This will take place on the site that originally initiated the friend request.
359          * In the section above where the confirming party makes a POST and
360          * retrieves xml status information, they are communicating with the following code.
361          */
362         if (!empty($_POST['source_url'])) {
363                 // We are processing an external confirmation to an introduction created by our user.
364                 $public_key =         $_POST['public_key'] ?? '';
365                 $dfrn_id    = hex2bin($_POST['dfrn_id']    ?? '');
366                 $source_url = hex2bin($_POST['source_url'] ?? '');
367                 $aes_key    =         $_POST['aes_key']    ?? '';
368                 $duplex     =  intval($_POST['duplex']     ?? 0);
369                 $page       =  intval($_POST['page']       ?? 0);
370
371                 $forum = (($page == 1) ? 1 : 0);
372                 $prv   = (($page == 2) ? 1 : 0);
373
374                 Logger::notice('requestee contacted', ['node' => $node]);
375
376                 Logger::debug('request', ['POST' => $_POST]);
377
378                 // If $aes_key is set, both of these items require unpacking from the hex transport encoding.
379
380                 if (!empty($aes_key)) {
381                         $aes_key = hex2bin($aes_key);
382                         $public_key = hex2bin($public_key);
383                 }
384
385                 // Find our user's account
386                 $user = DBA::selectFirst('user', [], ['nickname' => $node]);
387                 if (!DBA::isResult($user)) {
388                         $message = DI::l10n()->t('No user record found for \'%s\' ', $node);
389                         System::xmlExit(3, $message); // failure
390                         // NOTREACHED
391                 }
392
393                 $my_prvkey = $user['prvkey'];
394                 $local_uid = $user['uid'];
395
396
397                 if (!strstr($my_prvkey, 'PRIVATE KEY')) {
398                         $message = DI::l10n()->t('Our site encryption key is apparently messed up.');
399                         System::xmlExit(3, $message);
400                 }
401
402                 // verify everything
403
404                 $decrypted_source_url = "";
405                 openssl_private_decrypt($source_url, $decrypted_source_url, $my_prvkey);
406
407
408                 if (!strlen($decrypted_source_url)) {
409                         $message = DI::l10n()->t('Empty site URL was provided or URL could not be decrypted by us.');
410                         System::xmlExit(3, $message);
411                         // NOTREACHED
412                 }
413
414                 $contact = DBA::selectFirst('contact', [], ['url' => $decrypted_source_url, 'uid' => $local_uid]);
415                 if (!DBA::isResult($contact)) {
416                         if (strstr($decrypted_source_url, 'http:')) {
417                                 $newurl = str_replace('http:', 'https:', $decrypted_source_url);
418                         } else {
419                                 $newurl = str_replace('https:', 'http:', $decrypted_source_url);
420                         }
421
422                         $contact = DBA::selectFirst('contact', [], ['url' => $newurl, 'uid' => $local_uid]);
423                         if (!DBA::isResult($contact)) {
424                                 // this is either a bogus confirmation (?) or we deleted the original introduction.
425                                 $message = DI::l10n()->t('Contact record was not found for you on our site.');
426                                 System::xmlExit(3, $message);
427                                 return; // NOTREACHED
428                         }
429                 }
430
431                 $relation = $contact['rel'];
432
433                 // Decrypt all this stuff we just received
434
435                 $foreign_pubkey = $contact['site-pubkey'];
436                 $dfrn_record = $contact['id'];
437
438                 if (!$foreign_pubkey) {
439                         $message = DI::l10n()->t('Site public key not available in contact record for URL %s.', $decrypted_source_url);
440                         System::xmlExit(3, $message);
441                 }
442
443                 $decrypted_dfrn_id = "";
444                 openssl_public_decrypt($dfrn_id, $decrypted_dfrn_id, $foreign_pubkey);
445
446                 if (strlen($aes_key)) {
447                         $decrypted_aes_key = "";
448                         openssl_private_decrypt($aes_key, $decrypted_aes_key, $my_prvkey);
449                         $dfrn_pubkey = openssl_decrypt($public_key, 'AES-256-CBC', $decrypted_aes_key);
450                 } else {
451                         $dfrn_pubkey = $public_key;
452                 }
453
454                 if (DBA::exists('contact', ['dfrn-id' => $decrypted_dfrn_id])) {
455                         $message = DI::l10n()->t('The ID provided by your system is a duplicate on our system. It should work if you try again.');
456                         System::xmlExit(1, $message); // Birthday paradox - duplicate dfrn-id
457                         // NOTREACHED
458                 }
459
460                 $r = q("UPDATE `contact` SET `dfrn-id` = '%s', `pubkey` = '%s' WHERE `id` = %d",
461                         DBA::escape($decrypted_dfrn_id),
462                         DBA::escape($dfrn_pubkey),
463                         intval($dfrn_record)
464                 );
465                 if (!DBA::isResult($r)) {
466                         $message = DI::l10n()->t('Unable to set your contact credentials on our system.');
467                         System::xmlExit(3, $message);
468                 }
469
470                 // It's possible that the other person also requested friendship.
471                 // If it is a duplex relationship, ditch the issued-id if one exists.
472
473                 if ($duplex) {
474                         q("UPDATE `contact` SET `issued-id` = '' WHERE `id` = %d",
475                                 intval($dfrn_record)
476                         );
477                 }
478
479                 // We're good but now we have to scrape the profile photo and send notifications.
480                 $contact = DBA::selectFirst('contact', ['photo'], ['id' => $dfrn_record]);
481                 if (DBA::isResult($contact)) {
482                         $photo = $contact['photo'];
483                 } else {
484                         $photo = DI::baseUrl() . Contact::DEFAULT_AVATAR_PHOTO;
485                 }
486
487                 Contact::updateAvatar($dfrn_record, $photo);
488
489                 Logger::log('dfrn_confirm: request - photos imported');
490
491                 $new_relation = Contact::SHARING;
492
493                 if (($relation == Contact::FOLLOWER) || ($duplex)) {
494                         $new_relation = Contact::FRIEND;
495                 }
496
497                 if (($relation == Contact::FOLLOWER) && ($duplex)) {
498                         $duplex = 0;
499                 }
500
501                 $r = q("UPDATE `contact` SET
502                         `rel` = %d,
503                         `name-date` = '%s',
504                         `uri-date` = '%s',
505                         `blocked` = 0,
506                         `pending` = 0,
507                         `duplex` = %d,
508                         `forum` = %d,
509                         `prv` = %d,
510                         `network` = '%s' WHERE `id` = %d
511                 ",
512                         intval($new_relation),
513                         DBA::escape(DateTimeFormat::utcNow()),
514                         DBA::escape(DateTimeFormat::utcNow()),
515                         intval($duplex),
516                         intval($forum),
517                         intval($prv),
518                         DBA::escape(Protocol::DFRN),
519                         intval($dfrn_record)
520                 );
521                 if (!DBA::isResult($r)) {       // indicates schema is messed up or total db failure
522                         $message = DI::l10n()->t('Unable to update your contact profile details on our system');
523                         System::xmlExit(3, $message);
524                 }
525
526                 // Otherwise everything seems to have worked and we are almost done. Yay!
527                 // Send an email notification
528
529                 Logger::log('dfrn_confirm: request: info updated');
530
531                 $combined = null;
532                 $r = q("SELECT `contact`.*, `user`.*
533                         FROM `contact`
534                         LEFT JOIN `user` ON `contact`.`uid` = `user`.`uid`
535                         WHERE `contact`.`id` = %d
536                         LIMIT 1",
537                         intval($dfrn_record)
538                 );
539                 if (DBA::isResult($r)) {
540                         $combined = $r[0];
541
542                         if ($combined['notify-flags'] & Notification\Type::CONFIRM) {
543                                 $mutual = ($new_relation == Contact::FRIEND);
544                                 notification([
545                                         'type'  => Notification\Type::CONFIRM,
546                                         'otype' => Notification\ObjectType::INTRO,
547                                         'verb'  => ($mutual ? Activity::FRIEND : Activity::FOLLOW),
548                                         'uid'   => $combined['uid'],
549                                         'cid'   => $combined['id'],
550                                         'link'  => DI::baseUrl() . '/contact/' . $dfrn_record,
551                                 ]);
552                         }
553                 }
554
555                 System::xmlExit(0); // Success
556                 return; // NOTREACHED
557                 ////////////////////// End of this scenario ///////////////////////////////////////////////
558         }
559
560         // somebody arrived here by mistake or they are fishing. Send them to the homepage.
561         DI::baseUrl()->redirect();
562         // NOTREACHED
563 }