1<?php
2  /**
3   * Class for verifying Yubico One-Time-Passcodes
4   *
5   * @category    Auth
6   * @package     Auth_Yubico
7   * @author      Simon Josefsson <simon@yubico.com>, Olov Danielson <olov@yubico.com>
8   * @copyright   2007-2015 Yubico AB
9   * @license     http://opensource.org/licenses/bsd-license.php New BSD License
10   * @version     2.0
11   * @link        http://www.yubico.com/
12   */
13
14require_once 'PEAR.php';
15
16/**
17 * Class for verifying Yubico One-Time-Passcodes
18 *
19 * Simple example:
20 * <code>
21 * require_once 'Auth/Yubico.php';
22 * $otp = "ccbbddeertkrctjkkcglfndnlihhnvekchkcctif";
23 *
24 * # Generate a new id+key from https://api.yubico.com/get-api-key/
25 * $yubi = new Auth_Yubico('42', 'FOOBAR=');
26 * $auth = $yubi->verify($otp);
27 * if (PEAR::isError($auth)) {
28 *    print "<p>Authentication failed: " . $auth->getMessage();
29 *    print "<p>Debug output from server: " . $yubi->getLastResponse();
30 * } else {
31 *    print "<p>You are authenticated!";
32 * }
33 * </code>
34 */
35class Auth_Yubico
36{
37	/**#@+
38	 * @access private
39	 */
40
41	/**
42	 * Yubico client ID
43	 * @var string
44	 */
45	var $_id;
46
47	/**
48	 * Yubico client key
49	 * @var string
50	 */
51	var $_key;
52
53	/**
54	 * URL part of validation server
55	 * @var string
56	 */
57	var $_url;
58
59	/**
60	 * List with URL part of validation servers
61	 * @var array
62	 */
63	var $_url_list;
64
65	/**
66	 * index to _url_list
67	 * @var int
68	 */
69	var $_url_index;
70
71	/**
72	 * Last query to server
73	 * @var string
74	 */
75	var $_lastquery;
76
77	/**
78	 * Response from server
79	 * @var string
80	 */
81	var $_response;
82
83	/**
84	 * Flag whether to use https or not.
85	 * @var boolean
86	 */
87	var $_https;
88
89	/**
90	 * Flag whether to verify HTTPS server certificates or not.
91	 * @var boolean
92	 */
93	var $_httpsverify;
94
95	/**
96	 * Constructor
97	 *
98	 * Sets up the object
99	 * @param    string  $id     The client identity
100	 * @param    string  $key    The client MAC key (optional)
101	 * @param    boolean $https  Flag whether to use https (optional)
102	 * @param    boolean $httpsverify  Flag whether to use verify HTTPS
103	 *                                 server certificates (optional,
104	 *                                 default true)
105	 * @access public
106	 */
107	function Auth_Yubico($id, $key = '', $https = 0, $httpsverify = 1)
108	{
109		$this->_id =  $id;
110		$this->_key = base64_decode($key);
111		$this->_https = $https;
112		$this->_httpsverify = $httpsverify;
113	}
114
115	/**
116	 * Specify to use a different URL part for verification.
117	 * The default is "api.yubico.com/wsapi/verify".
118	 *
119	 * @param  string $url  New server URL part to use
120	 * @access public
121	 */
122	function setURLpart($url)
123	{
124		$this->_url = $url;
125	}
126
127	/**
128	 * Get URL part to use for validation.
129	 *
130	 * @return string  Server URL part
131	 * @access public
132	 */
133	function getURLpart()
134	{
135		if ($this->_url) {
136			return $this->_url;
137		} else {
138			return "api.yubico.com/wsapi/verify";
139		}
140	}
141
142
143	/**
144	 * Get next URL part from list to use for validation.
145	 *
146	 * @return mixed string with URL part of false if no more URLs in list
147	 * @access public
148	 */
149	function getNextURLpart()
150	{
151	  if ($this->_url_list) $url_list=$this->_url_list;
152	  else $url_list=array('api.yubico.com/wsapi/2.0/verify',
153			       'api2.yubico.com/wsapi/2.0/verify',
154			       'api3.yubico.com/wsapi/2.0/verify',
155			       'api4.yubico.com/wsapi/2.0/verify',
156			       'api5.yubico.com/wsapi/2.0/verify');
157
158	  if ($this->_url_index>=count($url_list)) return false;
159	  else return $url_list[$this->_url_index++];
160	}
161
162	/**
163	 * Resets index to URL list
164	 *
165	 * @access public
166	 */
167	function URLreset()
168	{
169	  $this->_url_index=0;
170	}
171
172	/**
173	 * Add another URLpart.
174	 *
175	 * @access public
176	 */
177	function addURLpart($URLpart)
178	{
179	  $this->_url_list[]=$URLpart;
180	}
181
182	/**
183	 * Return the last query sent to the server, if any.
184	 *
185	 * @return string  Request to server
186	 * @access public
187	 */
188	function getLastQuery()
189	{
190		return $this->_lastquery;
191	}
192
193	/**
194	 * Return the last data received from the server, if any.
195	 *
196	 * @return string  Output from server
197	 * @access public
198	 */
199	function getLastResponse()
200	{
201		return $this->_response;
202	}
203
204	/**
205	 * Parse input string into password, yubikey prefix,
206	 * ciphertext, and OTP.
207	 *
208	 * @param  string    Input string to parse
209	 * @param  string    Optional delimiter re-class, default is '[:]'
210	 * @return array     Keyed array with fields
211	 * @access public
212	 */
213	function parsePasswordOTP($str, $delim = '[:]')
214	{
215	  if (!preg_match("/^((.*)" . $delim . ")?" .
216			  "(([cbdefghijklnrtuv]{0,16})" .
217			  "([cbdefghijklnrtuv]{32}))$/i",
218			  $str, $matches)) {
219	    /* Dvorak? */
220	    if (!preg_match("/^((.*)" . $delim . ")?" .
221			    "(([jxe\.uidchtnbpygk]{0,16})" .
222			    "([jxe\.uidchtnbpygk]{32}))$/i",
223			    $str, $matches)) {
224	      return false;
225	    } else {
226	      $ret['otp'] = strtr($matches[3], "jxe.uidchtnbpygk", "cbdefghijklnrtuv");
227	    }
228	  } else {
229	    $ret['otp'] = $matches[3];
230	  }
231	  $ret['password'] = $matches[2];
232	  $ret['prefix'] = $matches[4];
233	  $ret['ciphertext'] = $matches[5];
234	  return $ret;
235	}
236
237	/* TODO? Add functions to get parsed parts of server response? */
238
239	/**
240	 * Parse parameters from last response
241	 *
242	 * example: getParameters("timestamp", "sessioncounter", "sessionuse");
243	 *
244	 * @param  array @parameters  Array with strings representing
245	 *                            parameters to parse
246	 * @return array  parameter array from last response
247	 * @access public
248	 */
249	function getParameters($parameters)
250	{
251	  if ($parameters == null) {
252	    $parameters = array('timestamp', 'sessioncounter', 'sessionuse');
253	  }
254	  $param_array = array();
255	  foreach ($parameters as $param) {
256	    if(!preg_match("/" . $param . "=([0-9]+)/", $this->_response, $out)) {
257	      return PEAR::raiseError('Could not parse parameter ' . $param . ' from response');
258	    }
259	    $param_array[$param]=$out[1];
260	  }
261	  return $param_array;
262	}
263
264	/**
265	 * Verify Yubico OTP against multiple URLs
266	 * Protocol specification 2.0 is used to construct validation requests
267	 *
268	 * @param string $token        Yubico OTP
269	 * @param int $use_timestamp   1=>send request with &timestamp=1 to
270	 *                             get timestamp and session information
271	 *                             in the response
272	 * @param boolean $wait_for_all  If true, wait until all
273	 *                               servers responds (for debugging)
274	 * @param string $sl           Sync level in percentage between 0
275	 *                             and 100 or "fast" or "secure".
276	 * @param int $timeout         Max number of seconds to wait
277	 *                             for responses
278	 * @return mixed               PEAR error on error, true otherwise
279	 * @access public
280	 */
281	function verify($token, $use_timestamp=null, $wait_for_all=False,
282			$sl=null, $timeout=null)
283	{
284	  /* Construct parameters string */
285	  $ret = $this->parsePasswordOTP($token);
286	  if (!$ret) {
287	    return PEAR::raiseError('Could not parse Yubikey OTP');
288	  }
289	  $params = array('id'=>$this->_id,
290			  'otp'=>$ret['otp'],
291			  'nonce'=>md5(uniqid(rand())));
292	  /* Take care of protocol version 2 parameters */
293	  if ($use_timestamp) $params['timestamp'] = 1;
294	  if ($sl) $params['sl'] = $sl;
295	  if ($timeout) $params['timeout'] = $timeout;
296	  ksort($params);
297	  $parameters = '';
298	  foreach($params as $p=>$v) $parameters .= "&" . $p . "=" . $v;
299	  $parameters = ltrim($parameters, "&");
300
301	  /* Generate signature. */
302	  if($this->_key <> "") {
303	    $signature = base64_encode(hash_hmac('sha1', $parameters,
304						 $this->_key, true));
305	    $signature = preg_replace('/\+/', '%2B', $signature);
306	    $parameters .= '&h=' . $signature;
307	  }
308
309	  /* Generate and prepare request. */
310	  $this->_lastquery=null;
311	  $this->URLreset();
312	  $mh = curl_multi_init();
313	  $ch = array();
314	  while($URLpart=$this->getNextURLpart())
315	    {
316	      /* Support https. */
317	      if ($this->_https) {
318		$query = "https://";
319	      } else {
320		$query = "http://";
321	      }
322	      $query .= $URLpart . "?" . $parameters;
323
324	      if ($this->_lastquery) { $this->_lastquery .= " "; }
325	      $this->_lastquery .= $query;
326
327	      $handle = curl_init($query);
328	      curl_setopt($handle, CURLOPT_USERAGENT, "PEAR Auth_Yubico");
329	      curl_setopt($handle, CURLOPT_RETURNTRANSFER, 1);
330	      if (!$this->_httpsverify) {
331		curl_setopt($handle, CURLOPT_SSL_VERIFYPEER, 0);
332		curl_setopt($handle, CURLOPT_SSL_VERIFYHOST, 0);
333	      }
334	      curl_setopt($handle, CURLOPT_FAILONERROR, true);
335	      /* If timeout is set, we better apply it here as well
336	         in case the validation server fails to follow it.
337	      */
338	      if ($timeout) curl_setopt($handle, CURLOPT_TIMEOUT, $timeout);
339	      curl_multi_add_handle($mh, $handle);
340
341	      $ch[(int)$handle] = $handle;
342	    }
343
344	  /* Execute and read request. */
345	  $this->_response=null;
346	  $replay=False;
347	  $valid=False;
348	  do {
349	    /* Let curl do its work. */
350	    while (($mrc = curl_multi_exec($mh, $active))
351		   == CURLM_CALL_MULTI_PERFORM)
352	      ;
353
354	    while ($info = curl_multi_info_read($mh)) {
355	      if ($info['result'] == CURLE_OK) {
356
357		/* We have a complete response from one server. */
358
359		$str = curl_multi_getcontent($info['handle']);
360		$cinfo = curl_getinfo ($info['handle']);
361
362		if ($wait_for_all) { # Better debug info
363		  $this->_response .= 'URL=' . $cinfo['url'] ."\n"
364		    . $str . "\n";
365		}
366
367		if (preg_match("/status=([a-zA-Z0-9_]+)/", $str, $out)) {
368		  $status = $out[1];
369
370		  /*
371		   * There are 3 cases.
372		   *
373		   * 1. OTP or Nonce values doesn't match - ignore
374		   * response.
375		   *
376		   * 2. We have a HMAC key.  If signature is invalid -
377		   * ignore response.  Return if status=OK or
378		   * status=REPLAYED_OTP.
379		   *
380		   * 3. Return if status=OK or status=REPLAYED_OTP.
381		   */
382		  if (!preg_match("/otp=".$params['otp']."/", $str) ||
383		      !preg_match("/nonce=".$params['nonce']."/", $str)) {
384		    /* Case 1. Ignore response. */
385		  }
386		  elseif ($this->_key <> "") {
387		    /* Case 2. Verify signature first */
388		    $rows = explode("\r\n", trim($str));
389		    $response=array();
390		    while (list($key, $val) = each($rows)) {
391		      /* = is also used in BASE64 encoding so we only replace the first = by # which is not used in BASE64 */
392		      $val = preg_replace('/=/', '#', $val, 1);
393		      $row = explode("#", $val);
394		      $response[$row[0]] = $row[1];
395		    }
396
397		    $parameters=array('nonce','otp', 'sessioncounter', 'sessionuse', 'sl', 'status', 't', 'timeout', 'timestamp');
398		    sort($parameters);
399		    $check=Null;
400		    foreach ($parameters as $param) {
401		      if (array_key_exists($param, $response)) {
402			if ($check) $check = $check . '&';
403			$check = $check . $param . '=' . $response[$param];
404		      }
405		    }
406
407		    $checksignature =
408		      base64_encode(hash_hmac('sha1', utf8_encode($check),
409					      $this->_key, true));
410
411		    if($response['h'] == $checksignature) {
412		      if ($status == 'REPLAYED_OTP') {
413			if (!$wait_for_all) { $this->_response = $str; }
414			$replay=True;
415		      }
416		      if ($status == 'OK') {
417			if (!$wait_for_all) { $this->_response = $str; }
418			$valid=True;
419		      }
420		    }
421		  } else {
422		    /* Case 3. We check the status directly */
423		    if ($status == 'REPLAYED_OTP') {
424		      if (!$wait_for_all) { $this->_response = $str; }
425		      $replay=True;
426		    }
427		    if ($status == 'OK') {
428		      if (!$wait_for_all) { $this->_response = $str; }
429		      $valid=True;
430		    }
431		  }
432		}
433		if (!$wait_for_all && ($valid || $replay))
434		  {
435		    /* We have status=OK or status=REPLAYED_OTP, return. */
436		    foreach ($ch as $h) {
437		      curl_multi_remove_handle($mh, $h);
438		      curl_close($h);
439		    }
440		    curl_multi_close($mh);
441		    if ($replay) return PEAR::raiseError('REPLAYED_OTP');
442		    if ($valid) return true;
443		    return PEAR::raiseError($status);
444		  }
445
446		curl_multi_remove_handle($mh, $info['handle']);
447		curl_close($info['handle']);
448		unset ($ch[(int)$info['handle']]);
449	      }
450	      curl_multi_select($mh);
451	    }
452	  } while ($active);
453
454	  /* Typically this is only reached for wait_for_all=true or
455	   * when the timeout is reached and there is no
456	   * OK/REPLAYED_REQUEST answer (think firewall).
457	   */
458
459	  foreach ($ch as $h) {
460	    curl_multi_remove_handle ($mh, $h);
461	    curl_close ($h);
462	  }
463	  curl_multi_close ($mh);
464
465	  if ($replay) return PEAR::raiseError('REPLAYED_OTP');
466	  if ($valid) return true;
467	  return PEAR::raiseError('NO_VALID_ANSWER');
468	}
469}
470?>
471