<?php
/**
 * Class for verifying Yubico One-Time-Passcodes
 *
 * @category    Auth
 * @package     Auth_Yubico
 * @author      Simon Josefsson <simon@yubico.com>, Olov Danielson <olov@yubico.com>
 * @copyright   2007-2012 Yubico AB
 * @license     http://opensource.org/licenses/bsd-license.php New BSD License
 * @version     2.0
 * @link        http://www.yubico.com/
 *
 * Simple example:
 * <code>
 * require_once 'Auth/Yubico.php';
 * $otp = "ccbbddeertkrctjkkcglfndnlihhnvekchkcctif";
 *
 * # Generate a new id+key from https://api.yubico.com/get-api-key/
 * try {
 *   $yubi = new Auth_Yubico('42', 'FOOBAR=');
 *   $yubi->verify($otp);
 * } catch $e {
 *   # authentication failed
 *   # use $e->getMessage() for debug
 * }
 * # authentication succeeded
 * </code>
 */
class Auth_Yubico
{
	/**#@+
	 * @access private
	 */

	/**
	 * Yubico client ID
	 * @var string
	 */
	var $_id;

	/**
	 * Yubico client key
	 * @var string
	 */
	var $_key;

	/**
	 * URL part of validation server
	 * @var string
	 */
	var $_url;

	/**
	 * List with URL part of validation servers
	 * @var array
	 */
	var $_url_list;

	/**
	 * index to _url_list
	 * @var int
	 */
	var $_url_index;

	/**
	 * Last query to server
	 * @var string
	 */
	var $_lastquery;

	/**
	 * Response from server
	 * @var string
	 */
	var $_response;

	/**
	 * Flag whether to use https or not.
	 * @var boolean
	 */
	var $_https;

	/**
	 * Flag whether to verify HTTPS server certificates or not.
	 * @var boolean
	 */
	var $_httpsverify;

	/**
	 * Constructor
	 *
	 * Sets up the object
	 * @param    string  $id     The client identity
	 * @param    string  $key    The client MAC key (optional)
	 * @param    boolean $https  Flag whether to use https (optional)
	 * @param    boolean $httpsverify  Flag whether to use verify HTTPS
	 *                                 server certificates (optional,
	 *                                 default true)
	 * @access public
	 */
	function Auth_Yubico($id, $key = '', $https = 0, $httpsverify = 1)
	{
		$this->_id =  $id;
		$this->_key = base64_decode($key);
		$this->_https = $https;
		$this->_httpsverify = $httpsverify;

                if($this->_https) $this->test_curl_ssl_support();
        }

        /**
         * Test if Curl support SSL
         * Will throw exception if curl was not complied with SSL support
         */
        private function test_curl_ssl_support()
        {
                if(!($version = curl_version()) ||
                   !($version['features'] & CURL_VERSION_SSL)) {
                  throw new Exception('HTTPS requested while Curl not compiled with SSL');
                }
        }

	/**
	 * Specify to use a different URL part for verification.
	 * The default is "api.yubico.com/wsapi/verify".
	 *
	 * @param  string $url  New server URL part to use
	 * @access public
	 */
	function setURLpart($url)
	{
		$this->_url = $url;
	}

	/**
	 * Get URL part to use for validation.
	 *
	 * @return string  Server URL part
	 * @access public
	 */
	function getURLpart()
	{
		if ($this->_url) {
			return $this->_url;
		} else {
			return "api.yubico.com/wsapi/verify";
		}
	}


	/**
	 * Get next URL part from list to use for validation.
	 *
	 * @return mixed string with URL part of false if no more URLs in list
	 * @access public
	 */
	function getNextURLpart()
	{
	  if ($this->_url_list) $url_list=$this->_url_list;
	  else $url_list=array('api.yubico.com/wsapi/2.0/verify',
			       'api2.yubico.com/wsapi/2.0/verify', 
			       'api3.yubico.com/wsapi/2.0/verify', 
			       'api4.yubico.com/wsapi/2.0/verify',
			       'api5.yubico.com/wsapi/2.0/verify');
	  
	  if ($this->_url_index>=count($url_list)) return false;
	  else return $url_list[$this->_url_index++];
	}

	/**
	 * Resets index to URL list
	 *
	 * @access public
	 */
	function URLreset()
	{
	  $this->_url_index=0;
	}

	/**
	 * Add another URLpart.
	 *
	 * @access public
	 */
	function addURLpart($URLpart) 
	{
	  $this->_url_list[]=$URLpart;
	}
	
	/**
	 * Return the last query sent to the server, if any.
	 *
	 * @return string  Request to server
	 * @access public
	 */
	function getLastQuery()
	{
		return $this->_lastquery;
	}

	/**
	 * Return the last data received from the server, if any.
	 *
	 * @return string  Output from server
	 * @access public
	 */
	function getLastResponse()
	{
		return $this->_response;
	}

	/**
	 * Parse input string into password, yubikey prefix,
	 * ciphertext, and OTP.
	 *
	 * @param  string    Input string to parse
	 * @param  string    Optional delimiter re-class, default is '[:]'
	 * @return array     Keyed array with fields
	 * @access public
	 */
	function parsePasswordOTP($str, $delim = '[:]')
	{
	  if (!preg_match("/^((.*)" . $delim . ")?" .
			  "(([cbdefghijklnrtuvCBDEFGHIJKLNRTUV]{0,16})" .
			  "([cbdefghijklnrtuvCBDEFGHIJKLNRTUV]{32}))$/",
			  $str, $matches)) {
	    /* Dvorak? */
	    if (!preg_match("/^((.*)" . $delim . ")?" .
			    "(([jxe.uidchtnbpygkJXE.UIDCHTNBPYGK]{0,16})" .
			    "([jxe.uidchtnbpygkJXE.UIDCHTNBPYGK]{32}))$/",
			    $str, $matches)) {
	      return false;
	    } else {
	      $ret['otp'] = strtr($matches[3], "jxe.uidchtnbpygk", "cbdefghijklnrtuv");
	    }
	  } else {
	    $ret['otp'] = $matches[3];
	  }
	  $ret['password'] = $matches[2];
	  $ret['prefix'] = $matches[4];
	  $ret['ciphertext'] = $matches[5];
	  return $ret;
	}

	/* TODO? Add functions to get parsed parts of server response? */

	/**
	 * Parse parameters from last response
	 *
	 * example: getParameters("timestamp", "sessioncounter", "sessionuse");
	 *
	 * @param  array @parameters  Array with strings representing
	 *                            parameters to parse
	 * @return array  parameter array from last response
	 * @access public
	 */
	function getParameters($parameters)
	{
	  if ($parameters == null) {
	    $parameters = array('timestamp', 'sessioncounter', 'sessionuse');
	  }
	  $param_array = array();
	  foreach ($parameters as $param) {
	    if(!preg_match("/" . $param . "=([0-9]+)/", $this->_response, $out)) {
	      throw new Exception('Could not parse parameter ' . $param . ' from response');
	    }
	    $param_array[$param]=$out[1];
	  }
	  return $param_array;
	}

	/**
	 * Verify Yubico OTP against multiple URLs
	 * Protocol specification 2.0 is used to construct validation requests
	 *
	 * @param string $token        Yubico OTP
	 * @param int $use_timestamp   1=>send request with &timestamp=1 to
	 *                             get timestamp and session information
	 *                             in the response
	 * @param boolean $wait_for_all  If true, wait until all
	 *                               servers responds (for debugging)
	 * @param string $sl           Sync level in percentage between 0
	 *                             and 100 or "fast" or "secure".
	 * @param int $timeout         Max number of seconds to wait
	 *                             for responses
	 * @access public
	 */
	function verify($token, $use_timestamp=null, $wait_for_all=False,
			$sl=null, $timeout=null)
	{
	  /* Construct parameters string */
	  $ret = $this->parsePasswordOTP($token);
	  if (!$ret) {
	    throw new Exception('Could not parse YubiKey OTP');
	  }
	  $params = array('id'=>$this->_id, 
			  'otp'=>$ret['otp'],
			  'nonce'=>md5(uniqid(rand())));
	  /* Take care of protocol version 2 parameters */
	  if ($use_timestamp) $params['timestamp'] = 1;
	  if ($sl) $params['sl'] = $sl;
	  if ($timeout) $params['timeout'] = $timeout;
	  ksort($params);
	  $parameters = '';
	  foreach($params as $p=>$v) $parameters .= "&" . $p . "=" . $v;
	  $parameters = ltrim($parameters, "&");
	  
	  /* Generate signature. */
	  if($this->_key <> "") {
	    $signature = base64_encode(hash_hmac('sha1', $parameters,
						 $this->_key, true));
	    $signature = preg_replace('/\+/', '%2B', $signature);
	    $parameters .= '&h=' . $signature;
	  }

	  /* Generate and prepare request. */
	  $this->_lastquery=null;
	  $this->URLreset();
	  $mh = curl_multi_init();
	  $ch = array();
	  while($URLpart=$this->getNextURLpart()) 
	    {
	      /* Support https. */
	      if ($this->_https) {
		$query = "https://";
	      } else {
		$query = "http://";
	      }
	      $query .= $URLpart . "?" . $parameters;

	      if ($this->_lastquery) { $this->_lastquery .= " "; }
	      $this->_lastquery .= $query;
	      
	      $handle = curl_init($query);
	      curl_setopt($handle, CURLOPT_USERAGENT, "Auth_Yubico");
	      curl_setopt($handle, CURLOPT_RETURNTRANSFER, 1);
	      if (!$this->_httpsverify) {
		curl_setopt($handle, CURLOPT_SSL_VERIFYPEER, 0);
	      }
	      curl_setopt($handle, CURLOPT_FAILONERROR, true);
	      /* If timeout is set, we better apply it here as well
	         in case the validation server fails to follow it. 
	      */ 
	      if ($timeout) curl_setopt($handle, CURLOPT_TIMEOUT, $timeout);
	      curl_multi_add_handle($mh, $handle);
	      
	      $ch[$handle] = $handle;
	    }

	  /* Execute and read request. */
	  $this->_response=null;
	  $replay=False;
	  $valid=False;
	  do {
	    /* Let curl do its work. */
	    while (($mrc = curl_multi_exec($mh, $active))
		   == CURLM_CALL_MULTI_PERFORM)
	      ;

	    while ($info = curl_multi_info_read($mh)) {
	      if ($info['result'] == CURLE_OK) {

		/* We have a complete response from one server. */

		$str = curl_multi_getcontent($info['handle']);
		$cinfo = curl_getinfo ($info['handle']);
		
		if ($wait_for_all) { # Better debug info
		  $this->_response .= 'URL=' . $cinfo['url'] ."\n"
		    . $str . "\n";
		}

		if (preg_match("/status=([a-zA-Z0-9_]+)/", $str, $out)) {
		  $status = $out[1];

		  /* 
		   * There are 3 cases.
		   *
		   * 1. OTP or Nonce values doesn't match - ignore
		   * response.
		   *
		   * 2. We have a HMAC key.  If signature is invalid -
		   * ignore response.  Return if status=OK or
		   * status=REPLAYED_OTP.
		   *
		   * 3. Return if status=OK or status=REPLAYED_OTP.
		   */
		  if (!preg_match("/otp=".$params['otp']."/", $str) ||
		      !preg_match("/nonce=".$params['nonce']."/", $str)) {
		    /* Case 1. Ignore response. */
		  } 
		  elseif ($this->_key <> "") {
		    /* Case 2. Verify signature first */
		    $rows = explode("\r\n", trim($str));
		    $response=array();
		    while (list($key, $val) = each($rows)) {
		      /* = is also used in BASE64 encoding so we only replace the first = by # which is not used in BASE64 */
		      $val = preg_replace('/=/', '#', $val, 1);
		      $row = explode("#", $val);
		      $response[$row[0]] = $row[1];
		    }
		    
		    $parameters=array('nonce','otp', 'sessioncounter', 'sessionuse', 'sl', 'status', 't', 'timeout', 'timestamp');
		    sort($parameters);
		    $check=Null;
		    foreach ($parameters as $param) {
		      if (array_key_exists($param, $response)) {
			if ($check) $check = $check . '&';
			$check = $check . $param . '=' . $response[$param];
		      }
		    }

		    $checksignature =
		      base64_encode(hash_hmac('sha1', utf8_encode($check),
					      $this->_key, true));

		    if($response['h'] == $checksignature) {
		      if ($status == 'REPLAYED_OTP') {
			if (!$wait_for_all) { $this->_response = $str; }
			$replay=True;
		      } 
		      if ($status == 'OK') {
			if (!$wait_for_all) { $this->_response = $str; }
			$valid=True;
		      }
		    }
		  } else {
		    /* Case 3. We check the status directly */
		    if ($status == 'REPLAYED_OTP') {
		      if (!$wait_for_all) { $this->_response = $str; }
		      $replay=True;
		    } 
		    if ($status == 'OK') {
		      if (!$wait_for_all) { $this->_response = $str; }
		      $valid=True;
		    }
		  }
		}
		if (!$wait_for_all && ($valid || $replay)) 
		  {
		    /* We have status=OK or status=REPLAYED_OTP, return. */
		    foreach ($ch as $h) {
		      curl_multi_remove_handle($mh, $h);
		      curl_close($h);
		    }
		    curl_multi_close($mh);
		    if ($replay) throw new Exception('REPLAYED_OTP');
		    if ($valid) return true;
		    throw new Exception($status);
		  }
		
		curl_multi_remove_handle($mh, $info['handle']);
		curl_close($info['handle']);
		unset ($ch[$info['handle']]);
	      }
	      curl_multi_select($mh);
	    }
	  } while ($active);

	  /* Typically this is only reached for wait_for_all=true or
	   * when the timeout is reached and there is no
	   * OK/REPLAYED_REQUEST answer (think firewall).
	   */

	  foreach ($ch as $h) {
	    curl_multi_remove_handle ($mh, $h);
	    curl_close ($h);
	  }
	  curl_multi_close ($mh);
	  
	  if ($replay) throw new Exception('REPLAYED_OTP');
	  if ($valid) return true;
	  throw new Exception('NO_VALID_ANSWER');
	}
}
?>
