magicenvelope.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. <?php
  2. /**
  3. * StatusNet - the distributed open-source microblogging tool
  4. * Copyright (C) 2010, StatusNet, Inc.
  5. *
  6. * A sample module to show best practices for StatusNet plugins
  7. *
  8. * PHP version 5
  9. *
  10. * This program is free software: you can redistribute it and/or modify
  11. * it under the terms of the GNU Affero General Public License as published by
  12. * the Free Software Foundation, either version 3 of the License, or
  13. * (at your option) any later version.
  14. *
  15. * This program is distributed in the hope that it will be useful,
  16. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  17. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  18. * GNU Affero General Public License for more details.
  19. *
  20. * You should have received a copy of the GNU Affero General Public License
  21. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  22. *
  23. * @package StatusNet
  24. * @author James Walker <james@status.net>
  25. * @copyright 2010 StatusNet, Inc.
  26. * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0
  27. * @link http://status.net/
  28. */
  29. class MagicEnvelope
  30. {
  31. const ENCODING = 'base64url';
  32. const NS = 'http://salmon-protocol.org/ns/magic-env';
  33. protected $actor = null; // Profile of user who has signed the envelope
  34. protected $data = null; // When stored here it is _always_ base64url encoded
  35. protected $data_type = null;
  36. protected $encoding = null;
  37. protected $alg = null;
  38. protected $sig = null;
  39. /**
  40. * Extract envelope data from an XML document containing an <me:env> or <me:provenance> element.
  41. *
  42. * @param string XML source
  43. * @return mixed associative array of envelope data, or false on unrecognized input
  44. *
  45. * @fixme will spew errors to logs or output in case of XML parse errors
  46. * @fixme may give fatal errors if some elements are missing or invalid XML
  47. * @fixme calling DOMDocument::loadXML statically triggers warnings in strict mode
  48. */
  49. public function __construct($xml=null, Profile $actor=null) {
  50. if (!empty($xml)) {
  51. $dom = new DOMDocument();
  52. if (!$dom->loadXML($xml)) {
  53. throw new ServerException('Tried to load malformed XML as DOM');
  54. } elseif (!$this->fromDom($dom)) {
  55. throw new ServerException('Could not load MagicEnvelope from DOM');
  56. }
  57. } elseif ($actor instanceof Profile) {
  58. // So far we only allow setting with _either_ $xml _or_ $actor as that's
  59. // all our circumstances require. But it may be confusing for new developers.
  60. // The idea is that feeding XML must be followed by interpretation and then
  61. // running $magic_env->verify($profile), just as in SalmonAction->prepare(...)
  62. // and supplying an $actor (which right now has to be a User) will require
  63. // defining the $data, $data_type etc. attributes manually afterwards before
  64. // signing the envelope..
  65. $this->setActor($actor);
  66. }
  67. }
  68. /**
  69. * Retrieve Salmon keypair first by checking local database, but
  70. * if it's not found, attempt discovery if it has been requested.
  71. *
  72. * @param Profile $profile The profile we're looking up keys for.
  73. * @param boolean $discovery Network discovery if no local cache?
  74. */
  75. public function getKeyPair(Profile $profile, $discovery=false) {
  76. if (!$profile->isLocal()) common_debug('Getting magic-public-key for non-local profile id=='.$profile->getID());
  77. $magicsig = Magicsig::getKV('user_id', $profile->getID());
  78. if ($discovery && !$magicsig instanceof Magicsig) {
  79. if (!$profile->isLocal()) common_debug('magic-public-key not found, will do discovery for profile id=='.$profile->getID());
  80. // Throws exception on failure, but does not try to _load_ the keypair string.
  81. $keypair = $this->discoverKeyPair($profile);
  82. $magicsig = new Magicsig();
  83. $magicsig->user_id = $profile->getID();
  84. $magicsig->importKeys($keypair);
  85. // save the public key for this profile in our database.
  86. // TODO: If the profile generates a new key remotely, we must be able to replace
  87. // this (of course after callback-verification).
  88. $magicsig->insert();
  89. } elseif (!$magicsig instanceof Magicsig) { // No discovery request, so we'll give up.
  90. throw new ServerException(sprintf('No public key found for profile (id==%d)', $profile->id));
  91. }
  92. assert($magicsig->publicKey instanceof Crypt_RSA);
  93. return $magicsig;
  94. }
  95. /**
  96. * Get the Salmon keypair from a URI, uses XRD Discovery etc. Reasonably
  97. * you'll only get the public key ;)
  98. *
  99. * The string will (hopefully) be formatted as described in Magicsig specification:
  100. * https://salmon-protocol.googlecode.com/svn/trunk/draft-panzer-magicsig-01.html#anchor13
  101. *
  102. * @return string formatted as Magicsig keypair
  103. */
  104. public function discoverKeyPair(Profile $profile)
  105. {
  106. $signer_uri = $profile->getUri();
  107. if (empty($signer_uri)) {
  108. throw new ServerException(sprintf('Profile missing URI (id==%d)', $profile->getID()));
  109. }
  110. $disco = new Discovery();
  111. // Throws exception on lookup problems
  112. try {
  113. $xrd = $disco->lookup($signer_uri);
  114. } catch (Exception $e) {
  115. // Diaspora seems to require us to request the acct: uri
  116. $xrd = $disco->lookup($profile->getAcctUri());
  117. }
  118. common_debug('Will try to find magic-public-key from XRD of profile id=='.$profile->getID());
  119. $pubkey = null;
  120. if (Event::handle('MagicsigPublicKeyFromXRD', array($xrd, &$pubkey))) {
  121. $link = $xrd->get(Magicsig::PUBLICKEYREL);
  122. if (is_null($link)) {
  123. // TRANS: Exception.
  124. throw new Exception(_m('Unable to locate signer public key.'));
  125. }
  126. $pubkey = $link->href;
  127. }
  128. if (empty($pubkey)) {
  129. throw new ServerException('Empty Magicsig public key. A bug?');
  130. }
  131. // We have a public key element, let's hope it has proper key data.
  132. $keypair = false;
  133. $parts = explode(',', $pubkey);
  134. if (count($parts) == 2) {
  135. $keypair = $parts[1];
  136. } else {
  137. // Backwards compatibility check for separator bug in 0.9.0
  138. $parts = explode(';', $pubkey);
  139. if (count($parts) == 2) {
  140. $keypair = $parts[1];
  141. }
  142. }
  143. if ($keypair === false) {
  144. // For debugging clarity. Keypair did not pass count()-check above.
  145. // TRANS: Exception when public key was not properly formatted.
  146. throw new Exception(_m('Incorrectly formatted public key element.'));
  147. }
  148. return $keypair;
  149. }
  150. /**
  151. * The current MagicEnvelope spec as used in StatusNet 0.9.7 and later
  152. * includes both the original data and some signing metadata fields as
  153. * the input plaintext for the signature hash.
  154. *
  155. * @return string
  156. */
  157. public function signingText() {
  158. return implode('.', array($this->data, // this field is pre-base64'd
  159. Magicsig::base64_url_encode($this->data_type),
  160. Magicsig::base64_url_encode($this->encoding),
  161. Magicsig::base64_url_encode($this->alg)));
  162. }
  163. /**
  164. *
  165. * @param <type> $text
  166. * @param <type> $mimetype
  167. * @param Magicsig $magicsig Magicsig with private key available.
  168. *
  169. * @return MagicEnvelope object with all properties set
  170. *
  171. * @throws Exception of various kinds on signing failure
  172. */
  173. public function signMessage($text, $mimetype)
  174. {
  175. if (!$this->actor instanceof Profile) {
  176. throw new ServerException('No profile to sign message with is set.');
  177. } elseif (!$this->actor->isLocal()) {
  178. throw new ServerException('Cannot sign magic envelopes with remote users since we have no private key.');
  179. }
  180. // Find already stored key
  181. $magicsig = Magicsig::getKV('user_id', $this->actor->getID());
  182. if (!$magicsig instanceof Magicsig) {
  183. // and if it doesn't exist, it is time to create one!
  184. $magicsig = Magicsig::generate($this->actor->getUser());
  185. }
  186. assert($magicsig instanceof Magicsig);
  187. assert($magicsig->privateKey instanceof Crypt_RSA);
  188. // Prepare text and metadata for signing
  189. $this->data = Magicsig::base64_url_encode($text);
  190. $this->data_type = $mimetype;
  191. $this->encoding = self::ENCODING;
  192. $this->alg = $magicsig->getName();
  193. // Get the actual signature
  194. $this->sig = $magicsig->sign($this->signingText());
  195. }
  196. /**
  197. * Create an <me:env> XML representation of the envelope.
  198. *
  199. * @return string representation of XML document
  200. */
  201. public function toXML(Profile $target=null, $flavour=null) {
  202. $xs = new XMLStringer();
  203. $xs->startXML(); // header, to point out it's not HTML or anything...
  204. if (Event::handle('StartMagicEnvelopeToXML', array($this, $xs, $flavour, $target))) {
  205. // fall back to our default, normal Magic Envelope XML.
  206. // the $xs element _may_ have had elements added, or could get in the end event
  207. $xs->elementStart('me:env', array('xmlns:me' => self::NS));
  208. $xs->element('me:data', array('type' => $this->data_type), $this->data);
  209. $xs->element('me:encoding', null, $this->encoding);
  210. $xs->element('me:alg', null, $this->alg);
  211. $xs->element('me:sig', null, $this->getSignature());
  212. $xs->elementEnd('me:env');
  213. Event::handle('EndMagicEnvelopeToXML', array($this, $xs, $flavour, $target));
  214. }
  215. return $xs->getString();
  216. }
  217. /*
  218. * Extract the contained XML payload, and insert a copy of the envelope
  219. * signature data as an <me:provenance> section.
  220. *
  221. * @return DOMDocument of Atom entry
  222. *
  223. * @fixme in case of XML parsing errors, this will spew to the error log or output
  224. */
  225. public function getPayload()
  226. {
  227. $dom = new DOMDocument();
  228. if (!$dom->loadXML(Magicsig::base64_url_decode($this->data))) {
  229. throw new ClientException('Malformed XML in Salmon payload');
  230. }
  231. switch ($this->data_type) {
  232. case 'application/atom+xml':
  233. if ($dom->documentElement->namespaceURI !== Activity::ATOM
  234. || $dom->documentElement->tagName !== 'entry') {
  235. throw new ServerException(_m('Salmon post must be an Atom entry.'));
  236. }
  237. $prov = $dom->createElementNS(self::NS, 'me:provenance');
  238. $prov->setAttribute('xmlns:me', self::NS);
  239. $data = $dom->createElementNS(self::NS, 'me:data', $this->data);
  240. $data->setAttribute('type', $this->data_type);
  241. $prov->appendChild($data);
  242. $enc = $dom->createElementNS(self::NS, 'me:encoding', $this->encoding);
  243. $prov->appendChild($enc);
  244. $alg = $dom->createElementNS(self::NS, 'me:alg', $this->alg);
  245. $prov->appendChild($alg);
  246. $sig = $dom->createElementNS(self::NS, 'me:sig', $this->getSignature());
  247. $prov->appendChild($sig);
  248. $dom->documentElement->appendChild($prov);
  249. break;
  250. default:
  251. throw new ClientException('Unknown Salmon payload data type');
  252. }
  253. return $dom;
  254. }
  255. public function getSignature()
  256. {
  257. if (empty($this->sig)) {
  258. throw new ServerException('You must first call signMessage before getSignature');
  259. }
  260. return $this->sig;
  261. }
  262. public function getSignatureAlgorithm()
  263. {
  264. return $this->alg;
  265. }
  266. public function getData()
  267. {
  268. return $this->data;
  269. }
  270. public function getDataType()
  271. {
  272. return $this->data_type;
  273. }
  274. public function getEncoding()
  275. {
  276. return $this->encoding;
  277. }
  278. /**
  279. * Find the author URI referenced in the payload Atom entry.
  280. *
  281. * @return string URI for author
  282. * @throws ServerException on failure
  283. */
  284. public function getAuthorUri() {
  285. $doc = $this->getPayload();
  286. $authors = $doc->documentElement->getElementsByTagName('author');
  287. foreach ($authors as $author) {
  288. $uris = $author->getElementsByTagName('uri');
  289. foreach ($uris as $uri) {
  290. return $uri->nodeValue;
  291. }
  292. }
  293. throw new ServerException('No author URI found in Salmon payload data');
  294. }
  295. /**
  296. * Attempt to verify cryptographic signing for parsed envelope data.
  297. * Requires network access to retrieve public key referenced by the envelope signer.
  298. *
  299. * Details of failure conditions are dumped to output log and not exposed to caller.
  300. *
  301. * @param Profile $profile profile used to get locally cached public signature key
  302. * or if necessary perform discovery on.
  303. *
  304. * @return boolean
  305. */
  306. public function verify(Profile $profile)
  307. {
  308. if ($this->alg != 'RSA-SHA256') {
  309. common_log(LOG_DEBUG, 'Salmon error: bad algorithm: '._ve($this->alg));
  310. return false;
  311. }
  312. if ($this->encoding != self::ENCODING) {
  313. common_log(LOG_DEBUG, 'Salmon error: bad encoding: '._ve($this->encoding));
  314. return false;
  315. }
  316. try {
  317. $magicsig = $this->getKeyPair($profile, true); // Do discovery too if necessary
  318. } catch (Exception $e) {
  319. common_log(LOG_DEBUG, "Salmon error: getKeyPair for profile id=={$profile->getID()}: "._ve($e->getMessage()));
  320. return false;
  321. }
  322. if (!$magicsig->verify($this->signingText(), $this->getSignature())) {
  323. common_log(LOG_INFO, 'Salmon signature verification failed for profile id=='.$profile->getID());
  324. // TRANS: Client error when incoming salmon slap signature does not verify cryptographically.
  325. throw new ClientException(_m('Salmon signature verification failed.'));
  326. }
  327. common_debug('Salmon signature verification successful for profile id=='.$profile->getID());
  328. $this->setActor($profile);
  329. return true;
  330. }
  331. /**
  332. * Extract envelope data from an XML document containing an <me:env> or <me:provenance> element.
  333. *
  334. * @param DOMDocument $dom
  335. * @return mixed associative array of envelope data, or false on unrecognized input
  336. *
  337. * @fixme may give fatal errors if some elements are missing
  338. */
  339. protected function fromDom(DOMDocument $dom)
  340. {
  341. $env_element = $dom->getElementsByTagNameNS(self::NS, 'env')->item(0);
  342. if (!$env_element) {
  343. $env_element = $dom->getElementsByTagNameNS(self::NS, 'provenance')->item(0);
  344. }
  345. if (!$env_element) {
  346. return false;
  347. }
  348. $data_element = $env_element->getElementsByTagNameNS(self::NS, 'data')->item(0);
  349. $sig_element = $env_element->getElementsByTagNameNS(self::NS, 'sig')->item(0);
  350. $this->data = preg_replace('/\s/', '', $data_element->nodeValue);
  351. $this->data_type = $data_element->getAttribute('type');
  352. $this->encoding = $env_element->getElementsByTagNameNS(self::NS, 'encoding')->item(0)->nodeValue;
  353. $this->alg = $env_element->getElementsByTagNameNS(self::NS, 'alg')->item(0)->nodeValue;
  354. $this->sig = preg_replace('/\s/', '', $sig_element->nodeValue);
  355. return true;
  356. }
  357. public function setActor(Profile $actor)
  358. {
  359. if ($this->actor instanceof Profile) {
  360. throw new ServerException('Cannot set a new actor profile for MagicEnvelope object.');
  361. }
  362. $this->actor = $actor;
  363. }
  364. public function getActor()
  365. {
  366. if (!$this->actor instanceof Profile) {
  367. throw new ServerException('No actor set for this magic envelope.');
  368. }
  369. return $this->actor;
  370. }
  371. /**
  372. * Encode the given string as a signed MagicEnvelope XML document,
  373. * using the keypair for the given local user profile. We can of
  374. * course not sign a remote profile's slap, since we don't have the
  375. * private key.
  376. *
  377. * Side effects: will create and store a keypair on-demand if one
  378. * hasn't already been generated for this user. This can be very slow
  379. * on some systems.
  380. *
  381. * @param string $text XML fragment to sign, assumed to be Atom
  382. * @param User $user User who cryptographically signs $text
  383. *
  384. * @return MagicEnvelope object complete with signature
  385. *
  386. * @throws Exception on bad profile input or key generation problems
  387. */
  388. public static function signAsUser($text, User $user)
  389. {
  390. $magic_env = new MagicEnvelope(null, $user->getProfile());
  391. $magic_env->signMessage($text, 'application/atom+xml');
  392. return $magic_env;
  393. }
  394. }