DiasporaPlugin.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. <?php
  2. /*
  3. * GNU Social - a federating social network
  4. * Copyright (C) 2015, Free Software Foundation, Inc.
  5. *
  6. * This program is free software: you can redistribute it and/or modify
  7. * it under the terms of the GNU Affero General Public License as published by
  8. * the Free Software Foundation, either version 3 of the License, or
  9. * (at your option) any later version.
  10. *
  11. * This program is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. * GNU Affero General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU Affero General Public License
  17. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. */
  19. if (!defined('GNUSOCIAL')) { exit(1); }
  20. /**
  21. * Diaspora federation protocol plugin for GNU Social
  22. *
  23. * Depends on:
  24. * - OStatus plugin
  25. * - WebFinger plugin
  26. *
  27. * @package ProtocolDiasporaPlugin
  28. * @maintainer Mikael Nordfeldth <mmn@hethane.se>
  29. */
  30. // Depends on OStatus of course.
  31. addPlugin('OStatus');
  32. class DiasporaPlugin extends Plugin
  33. {
  34. const PLUGIN_VERSION = '0.2.0';
  35. const REL_SEED_LOCATION = 'http://joindiaspora.com/seed_location';
  36. const REL_GUID = 'http://joindiaspora.com/guid';
  37. const REL_PUBLIC_KEY = 'diaspora-public-key';
  38. public function onEndAttachPubkeyToUserXRD(Magicsig $magicsig, XML_XRD $xrd, Profile $target)
  39. {
  40. // So far we've only handled RSA keys, but it can change in the future,
  41. // so be prepared. And remember to change the statically assigned type attribute below!
  42. assert($magicsig->publicKey instanceof \phpseclib\Crypt\RSA);
  43. $xrd->links[] = new XML_XRD_Element_Link(self::REL_PUBLIC_KEY,
  44. base64_encode($magicsig->exportPublicKey()), 'RSA');
  45. // Instead of choosing a random string, we calculate our GUID from the public key
  46. // by fingerprint through a sha256 hash.
  47. $xrd->links[] = new XML_XRD_Element_Link(self::REL_GUID,
  48. strtolower($magicsig->toFingerprint()));
  49. }
  50. public function onMagicsigPublicKeyFromXRD(XML_XRD $xrd, &$pubkey)
  51. {
  52. // See if we have a Diaspora public key in the XRD response
  53. $link = $xrd->get(self::REL_PUBLIC_KEY, 'RSA');
  54. if (!is_null($link)) {
  55. // If we do, decode it so we have the PKCS1 format (starts with -----BEGIN PUBLIC KEY-----)
  56. $pkcs1 = base64_decode($link->href);
  57. $magicsig = new Magicsig(Magicsig::DEFAULT_SIGALG); // Diaspora uses RSA-SHA256 (we do too)
  58. try {
  59. // Try to load the public key so we can get it in the standard Magic signature format
  60. $magicsig->loadPublicKeyPKCS1($pkcs1);
  61. // We found it and will now store it in $pubkey in a proper format!
  62. // This is how it would be found in a well implemented XRD according to the standard.
  63. $pubkey = 'data:application/magic-public-key,'.$magicsig->toString();
  64. common_debug('magic-public-key found in diaspora-public-key: '.$pubkey);
  65. return false;
  66. } catch (ServerException $e) {
  67. common_log(LOG_WARNING, $e->getMessage());
  68. }
  69. }
  70. return true;
  71. }
  72. public function onPluginVersion(array &$versions)
  73. {
  74. $versions[] = array('name' => 'Diaspora',
  75. 'version' => self::PLUGIN_VERSION,
  76. 'author' => 'Mikael Nordfeldth',
  77. 'homepage' => 'https://gnu.io/social',
  78. // TRANS: Plugin description.
  79. 'rawdescription' => _m('Follow people across social networks that implement '.
  80. 'the <a href="https://diasporafoundation.org/">Diaspora</a> federation protocol.'));
  81. return true;
  82. }
  83. public function onStartMagicEnvelopeToXML(MagicEnvelope $magic_env, XMLStringer $xs, $flavour=null, Profile $target=null)
  84. {
  85. // Since Diaspora doesn't use a separate namespace for their "extended"
  86. // salmon slap, we'll have to resort to this workaround hack.
  87. if ($flavour !== 'diaspora') {
  88. return true;
  89. }
  90. // WARNING: This changes the $magic_env contents! Be aware of it.
  91. /**
  92. * https://wiki.diasporafoundation.org/Federation_protocol_overview
  93. * http://www.rubydoc.info/github/Raven24/diaspora-federation/master/DiasporaFederation/Salmon/EncryptedSlap
  94. *
  95. * Constructing the encryption header
  96. */
  97. // For some reason diaspora wants the salmon slap in a <diaspora> header.
  98. $xs->elementStart('diaspora', array('xmlns'=>'https://joindiaspora.com/protocol'));
  99. /**
  100. * Choose an AES key and initialization vector, suitable for the
  101. * aes-256-cbc cipher. I shall refer to this as the “inner key”
  102. * and the “inner initialization vector (iv)”.
  103. */
  104. $inner_key = new \phpseclib\Crypt\AES(\phpseclib\Crypt\AES::MODE_CBC);
  105. $inner_key->setKeyLength(256); // set length to 256 bits (could be calculated, but let's be sure)
  106. $inner_key->setKey(common_random_rawstr(32)); // 32 bytes from a (pseudo) random source
  107. $inner_key->setIV(common_random_rawstr(16)); // 16 bytes is the block length
  108. /**
  109. * Construct the following XML snippet:
  110. * <decrypted_header>
  111. * <iv>((base64-encoded inner iv))</iv>
  112. * <aes_key>((base64-encoded inner key))</aes_key>
  113. * <author>
  114. * <name>Alice Exampleman</name>
  115. * <uri>acct:user@sender.example</uri>
  116. * </author>
  117. * </decrypted_header>
  118. */
  119. $decrypted_header = sprintf('<decrypted_header><iv>%1$s</iv><aes_key>%2$s</aes_key><author_id>%3$s</author_id></decrypted_header>',
  120. base64_encode($inner_key->iv),
  121. base64_encode($inner_key->key),
  122. $magic_env->getActor()->getAcctUri());
  123. /**
  124. * Construct another AES key and initialization vector suitable
  125. * for the aes-256-cbc cipher. I shall refer to this as the
  126. * “outer key” and the “outer initialization vector (iv)”.
  127. */
  128. $outer_key = new \phpseclib\Crypt\AES(\phpseclib\Crypt\AES::MODE_CBC);
  129. $outer_key->setKeyLength(256); // set length to 256 bits (could be calculated, but let's be sure)
  130. $outer_key->setKey(common_random_rawstr(32)); // 32 bytes from a (pseudo) random source
  131. $outer_key->setIV(common_random_rawstr(16)); // 16 bytes is the block length
  132. /**
  133. * Encrypt your <decrypted_header> XML snippet using the “outer key”
  134. * and “outer iv” (using the aes-256-cbc cipher). This encrypted
  135. * blob shall be referred to as “the ciphertext”.
  136. */
  137. $ciphertext = $outer_key->encrypt($decrypted_header, \phpseclib\Crypt\RSA::PADDING_PKCS1);
  138. /**
  139. * Construct the following JSON object, which shall be referred to
  140. * as “the outer aes key bundle”:
  141. * {
  142. * "iv": ((base64-encoded AES outer iv)),
  143. * "key": ((base64-encoded AES outer key))
  144. * }
  145. */
  146. $outer_bundle = json_encode(array(
  147. 'iv' => base64_encode($outer_key->iv),
  148. 'key' => base64_encode($outer_key->key),
  149. ));
  150. /**
  151. * Encrypt the “outer aes key bundle” with Bob’s RSA public key.
  152. * I shall refer to this as the “encrypted outer aes key bundle”.
  153. */
  154. common_debug('Diaspora creating "outer aes key bundle", will require magic-public-key');
  155. $key_fetcher = new MagicEnvelope();
  156. $remote_keys = $key_fetcher->getKeyPair($target, true); // actually just gets the public key
  157. $enc_outer = $remote_keys->publicKey->encrypt($outer_bundle, \phpseclib\Crypt\RSA::PADDING_PKCS1);
  158. /**
  159. * Construct the following JSON object, which I shall refer to as
  160. * the “encrypted header json object”:
  161. * {
  162. * "aes_key": ((base64-encoded encrypted outer aes key bundle)),
  163. * "ciphertext": ((base64-encoded ciphertextm from above))
  164. * }
  165. */
  166. $enc_header = json_encode(array(
  167. 'aes_key' => base64_encode($enc_outer),
  168. 'ciphertext' => base64_encode($ciphertext),
  169. ));
  170. /**
  171. * Construct the xml snippet:
  172. * <encrypted_header>((base64-encoded encrypted header json object))</encrypted_header>
  173. */
  174. $xs->element('encrypted_header', null, base64_encode($enc_header));
  175. /**
  176. * In order to prepare the payload message for inclusion in your
  177. * salmon slap, you will:
  178. *
  179. * 1. Encrypt the payload message using the aes-256-cbc cipher and
  180. * the “inner encryption key” and “inner encryption iv” you
  181. * chose earlier.
  182. * 2. Base64-encode the encrypted payload message.
  183. */
  184. $payload = $inner_key->encrypt($magic_env->getData(), \phpseclib\Crypt\RSA::PADDING_PKCS1);
  185. //FIXME: This means we don't actually put an <atom:entry> in the payload,
  186. // since Diaspora has its own update method! Silly me. Read up on:
  187. // https://wiki.diasporafoundation.org/Federation_Message_Semantics
  188. $magic_env->signMessage(base64_encode($payload), 'application/xml');
  189. // Since we have to change the content of me:data we'll just write the
  190. // whole thing from scratch. We _could_ otherwise have just manipulated
  191. // that element and added the encrypted_header in the EndMagicEnvelopeToXML event.
  192. $xs->elementStart('me:env', array('xmlns:me' => MagicEnvelope::NS));
  193. $xs->element('me:data', array('type' => $magic_env->getDataType()), $magic_env->getData());
  194. $xs->element('me:encoding', null, $magic_env->getEncoding());
  195. $xs->element('me:alg', null, $magic_env->getSignatureAlgorithm());
  196. $xs->element('me:sig', null, $magic_env->getSignature());
  197. $xs->elementEnd('me:env');
  198. $xs->elementEnd('entry');
  199. return false;
  200. }
  201. public function onSalmonSlap($endpoint_uri, MagicEnvelope $magic_env, Profile $target=null)
  202. {
  203. try {
  204. $envxml = $magic_env->toXML($target, 'diaspora');
  205. } catch (Exception $e) {
  206. common_log(LOG_ERR, sprintf('Could not generate Magic Envelope XML (diaspora flavour) for profile id=='.$target->getID().': '.$e->getMessage()));
  207. return false;
  208. }
  209. // Diaspora wants another POST format (base64url-encoded POST variable 'xml')
  210. $headers = array('Content-Type: application/x-www-form-urlencoded');
  211. // Another way to distinguish Diaspora from GNU social is that a POST with
  212. // $headers=array('Content-Type: application/magic-envelope+xml') would return
  213. // HTTP status code 422 Unprocessable Entity, at least as of 2015-10-04.
  214. try {
  215. $client = new HTTPClient();
  216. $client->setBody('xml=' . Magicsig::base64_url_encode($envxml));
  217. $response = $client->post($endpoint_uri, $headers);
  218. } catch (Exception $e) {
  219. common_log(LOG_ERR, "Diaspora-flavoured Salmon post to $endpoint_uri failed: " . $e->getMessage());
  220. return false;
  221. }
  222. // 200 OK is the best response
  223. // 202 Accepted is what we get from Diaspora for example
  224. if (!in_array($response->getStatus(), array(200, 202))) {
  225. common_log(LOG_ERR, sprintf('Salmon (from profile %d) endpoint %s returned status %s: %s',
  226. $magic_env->getActor()->getID(), $endpoint_uri, $response->getStatus(), $response->getBody()));
  227. return true;
  228. }
  229. // Success!
  230. return false;
  231. }
  232. }