magicenvelope.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  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 $data = null; // When stored here it is _always_ base64url encoded
  34. protected $data_type = null;
  35. protected $encoding = null;
  36. protected $alg = null;
  37. protected $sig = null;
  38. /**
  39. * Extract envelope data from an XML document containing an <me:env> or <me:provenance> element.
  40. *
  41. * @param string XML source
  42. * @return mixed associative array of envelope data, or false on unrecognized input
  43. *
  44. * @fixme will spew errors to logs or output in case of XML parse errors
  45. * @fixme may give fatal errors if some elements are missing or invalid XML
  46. * @fixme calling DOMDocument::loadXML statically triggers warnings in strict mode
  47. */
  48. public function __construct($xml=null) {
  49. if (!empty($xml)) {
  50. $dom = DOMDocument::loadXML($xml);
  51. if (!$dom instanceof DOMDocument) {
  52. throw new ServerException('Tried to load malformed XML as DOM');
  53. } elseif (!$this->fromDom($dom)) {
  54. throw new ServerException('Could not load MagicEnvelope from DOM');
  55. }
  56. }
  57. }
  58. /**
  59. * Retrieve Salmon keypair first by checking local database, but
  60. * if it's not found, attempt discovery if it has been requested.
  61. *
  62. * @param Profile $profile The profile we're looking up keys for.
  63. * @param boolean $discovery Network discovery if no local cache?
  64. */
  65. public function getKeyPair(Profile $profile, $discovery=false) {
  66. $magicsig = Magicsig::getKV('user_id', $profile->id);
  67. if ($discovery && !$magicsig instanceof Magicsig) {
  68. // Throws exception on failure, but does not try to _load_ the keypair string.
  69. $keypair = $this->discoverKeyPair($profile);
  70. $magicsig = new Magicsig();
  71. $magicsig->user_id = $profile->id;
  72. $magicsig->importKeys($keypair);
  73. // save the public key for this profile in our database.
  74. // TODO: If the profile generates a new key remotely, we must be able to replace
  75. // this (of course after callback-verification).
  76. $magicsig->insert();
  77. } elseif (!$magicsig instanceof Magicsig) { // No discovery request, so we'll give up.
  78. throw new ServerException(sprintf('No public key found for profile (id==%d)', $profile->id));
  79. }
  80. assert($magicsig->publicKey instanceof Crypt_RSA);
  81. return $magicsig;
  82. }
  83. /**
  84. * Get the Salmon keypair from a URI, uses XRD Discovery etc. Reasonably
  85. * you'll only get the public key ;)
  86. *
  87. * The string will (hopefully) be formatted as described in Magicsig specification:
  88. * https://salmon-protocol.googlecode.com/svn/trunk/draft-panzer-magicsig-01.html#anchor13
  89. *
  90. * @return string formatted as Magicsig keypair
  91. */
  92. public function discoverKeyPair(Profile $profile)
  93. {
  94. $signer_uri = $profile->getUri();
  95. if (empty($signer_uri)) {
  96. throw new ServerException(sprintf('Profile missing URI (id==%d)', $profile->id));
  97. }
  98. $disco = new Discovery();
  99. // Throws exception on lookup problems
  100. $xrd = $disco->lookup($signer_uri);
  101. $link = $xrd->get(Magicsig::PUBLICKEYREL);
  102. if (is_null($link)) {
  103. // TRANS: Exception.
  104. throw new Exception(_m('Unable to locate signer public key.'));
  105. }
  106. // We have a public key element, let's hope it has proper key data.
  107. $keypair = false;
  108. $parts = explode(',', $link->href);
  109. if (count($parts) == 2) {
  110. $keypair = $parts[1];
  111. } else {
  112. // Backwards compatibility check for separator bug in 0.9.0
  113. $parts = explode(';', $link->href);
  114. if (count($parts) == 2) {
  115. $keypair = $parts[1];
  116. }
  117. }
  118. if ($keypair === false) {
  119. // For debugging clarity. Keypair did not pass count()-check above.
  120. // TRANS: Exception when public key was not properly formatted.
  121. throw new Exception(_m('Incorrectly formatted public key element.'));
  122. }
  123. return $keypair;
  124. }
  125. /**
  126. * The current MagicEnvelope spec as used in StatusNet 0.9.7 and later
  127. * includes both the original data and some signing metadata fields as
  128. * the input plaintext for the signature hash.
  129. *
  130. * @return string
  131. */
  132. public function signingText() {
  133. return implode('.', array($this->data, // this field is pre-base64'd
  134. Magicsig::base64_url_encode($this->data_type),
  135. Magicsig::base64_url_encode($this->encoding),
  136. Magicsig::base64_url_encode($this->alg)));
  137. }
  138. /**
  139. *
  140. * @param <type> $text
  141. * @param <type> $mimetype
  142. * @param Magicsig $magicsig Magicsig with private key available.
  143. *
  144. * @return MagicEnvelope object with all properties set
  145. *
  146. * @throws Exception of various kinds on signing failure
  147. */
  148. public function signMessage($text, $mimetype, Magicsig $magicsig)
  149. {
  150. assert($magicsig->privateKey instanceof Crypt_RSA);
  151. // Prepare text and metadata for signing
  152. $this->data = Magicsig::base64_url_encode($text);
  153. $this->data_type = $mimetype;
  154. $this->encoding = self::ENCODING;
  155. $this->alg = $magicsig->getName();
  156. // Get the actual signature
  157. $this->sig = $magicsig->sign($this->signingText());
  158. }
  159. /**
  160. * Create an <me:env> XML representation of the envelope.
  161. *
  162. * @return string representation of XML document
  163. */
  164. public function toXML() {
  165. $xs = new XMLStringer();
  166. $xs->startXML();
  167. $xs->elementStart('me:env', array('xmlns:me' => self::NS));
  168. $xs->element('me:data', array('type' => $this->data_type), $this->data);
  169. $xs->element('me:encoding', null, $this->encoding);
  170. $xs->element('me:alg', null, $this->alg);
  171. $xs->element('me:sig', null, $this->getSignature());
  172. $xs->elementEnd('me:env');
  173. $string = $xs->getString();
  174. return $string;
  175. }
  176. /*
  177. * Extract the contained XML payload, and insert a copy of the envelope
  178. * signature data as an <me:provenance> section.
  179. *
  180. * @return DOMDocument of Atom entry
  181. *
  182. * @fixme in case of XML parsing errors, this will spew to the error log or output
  183. */
  184. public function getPayload()
  185. {
  186. $dom = new DOMDocument();
  187. if (!$dom->loadXML(Magicsig::base64_url_decode($this->data))) {
  188. throw new ServerException('Malformed XML in Salmon payload');
  189. }
  190. switch ($this->data_type) {
  191. case 'application/atom+xml':
  192. if ($dom->documentElement->namespaceURI !== Activity::ATOM
  193. || $dom->documentElement->tagName !== 'entry') {
  194. throw new ServerException(_m('Salmon post must be an Atom entry.'));
  195. }
  196. $prov = $dom->createElementNS(self::NS, 'me:provenance');
  197. $prov->setAttribute('xmlns:me', self::NS);
  198. $data = $dom->createElementNS(self::NS, 'me:data', $this->data);
  199. $data->setAttribute('type', $this->data_type);
  200. $prov->appendChild($data);
  201. $enc = $dom->createElementNS(self::NS, 'me:encoding', $this->encoding);
  202. $prov->appendChild($enc);
  203. $alg = $dom->createElementNS(self::NS, 'me:alg', $this->alg);
  204. $prov->appendChild($alg);
  205. $sig = $dom->createElementNS(self::NS, 'me:sig', $this->getSignature());
  206. $prov->appendChild($sig);
  207. $dom->documentElement->appendChild($prov);
  208. break;
  209. default:
  210. throw new ServerException('Unknown Salmon payload data type');
  211. }
  212. return $dom;
  213. }
  214. public function getSignature()
  215. {
  216. return $this->sig;
  217. }
  218. /**
  219. * Find the author URI referenced in the payload Atom entry.
  220. *
  221. * @return string URI for author
  222. * @throws ServerException on failure
  223. */
  224. public function getAuthorUri() {
  225. $doc = $this->getPayload();
  226. $authors = $doc->documentElement->getElementsByTagName('author');
  227. foreach ($authors as $author) {
  228. $uris = $author->getElementsByTagName('uri');
  229. foreach ($uris as $uri) {
  230. return $uri->nodeValue;
  231. }
  232. }
  233. throw new ServerException('No author URI found in Salmon payload data');
  234. }
  235. /**
  236. * Attempt to verify cryptographic signing for parsed envelope data.
  237. * Requires network access to retrieve public key referenced by the envelope signer.
  238. *
  239. * Details of failure conditions are dumped to output log and not exposed to caller.
  240. *
  241. * @param Profile $profile profile used to get locally cached public signature key
  242. * or if necessary perform discovery on.
  243. *
  244. * @return boolean
  245. */
  246. public function verify(Profile $profile)
  247. {
  248. if ($this->alg != 'RSA-SHA256') {
  249. common_log(LOG_DEBUG, "Salmon error: bad algorithm");
  250. return false;
  251. }
  252. if ($this->encoding != self::ENCODING) {
  253. common_log(LOG_DEBUG, "Salmon error: bad encoding");
  254. return false;
  255. }
  256. try {
  257. $magicsig = $this->getKeyPair($profile, true); // Do discovery too if necessary
  258. } catch (Exception $e) {
  259. common_log(LOG_DEBUG, "Salmon error: ".$e->getMessage());
  260. return false;
  261. }
  262. return $magicsig->verify($this->signingText(), $this->getSignature());
  263. }
  264. /**
  265. * Extract envelope data from an XML document containing an <me:env> or <me:provenance> element.
  266. *
  267. * @param DOMDocument $dom
  268. * @return mixed associative array of envelope data, or false on unrecognized input
  269. *
  270. * @fixme may give fatal errors if some elements are missing
  271. */
  272. protected function fromDom(DOMDocument $dom)
  273. {
  274. $env_element = $dom->getElementsByTagNameNS(self::NS, 'env')->item(0);
  275. if (!$env_element) {
  276. $env_element = $dom->getElementsByTagNameNS(self::NS, 'provenance')->item(0);
  277. }
  278. if (!$env_element) {
  279. return false;
  280. }
  281. $data_element = $env_element->getElementsByTagNameNS(self::NS, 'data')->item(0);
  282. $sig_element = $env_element->getElementsByTagNameNS(self::NS, 'sig')->item(0);
  283. $this->data = preg_replace('/\s/', '', $data_element->nodeValue);
  284. $this->data_type = $data_element->getAttribute('type');
  285. $this->encoding = $env_element->getElementsByTagNameNS(self::NS, 'encoding')->item(0)->nodeValue;
  286. $this->alg = $env_element->getElementsByTagNameNS(self::NS, 'alg')->item(0)->nodeValue;
  287. $this->sig = preg_replace('/\s/', '', $sig_element->nodeValue);
  288. return true;
  289. }
  290. /**
  291. * Encode the given string as a signed MagicEnvelope XML document,
  292. * using the keypair for the given local user profile. We can of
  293. * course not sign a remote profile's slap, since we don't have the
  294. * private key.
  295. *
  296. * Side effects: will create and store a keypair on-demand if one
  297. * hasn't already been generated for this user. This can be very slow
  298. * on some systems.
  299. *
  300. * @param string $text XML fragment to sign, assumed to be Atom
  301. * @param User $user User who cryptographically signs $text
  302. *
  303. * @return MagicEnvelope object complete with signature
  304. *
  305. * @throws Exception on bad profile input or key generation problems
  306. */
  307. public static function signAsUser($text, User $user)
  308. {
  309. // Find already stored key
  310. $magicsig = Magicsig::getKV('user_id', $user->id);
  311. if (!$magicsig instanceof Magicsig) {
  312. $magicsig = Magicsig::generate($user);
  313. }
  314. assert($magicsig instanceof Magicsig);
  315. assert($magicsig->privateKey instanceof Crypt_RSA);
  316. $magic_env = new MagicEnvelope();
  317. $magic_env->signMessage($text, 'application/atom+xml', $magicsig);
  318. return $magic_env;
  319. }
  320. }