yms/backend.php
author Dan
Mon, 03 Aug 2009 02:52:59 -0400
changeset 4 9fdc988ce46e
parent 2 bbdd428926b9
child 12 31387f4022e5
permissions -rw-r--r--
Added counter and ANY_CLIENT settings to ShowAESKey; Significant improvements to claim system: Added master switch for the whole system; Added ability for administrators to "su" to client ID 0 to manage pooled keys; Added ability for admins to release key when it is added

<?php

function yms_add_yubikey($key, $otp, $client_id = false, $enabled = true, $any_client = false, $notes = false)
{
  global $db, $session, $paths, $template, $plugins; // Common objects
  
  if ( $client_id === false )
    $client_id = $GLOBALS['yms_client_id'];
  
  $key = yms_tobinary($key);
  $otp = yms_tobinary($otp);
  
  if ( strlen($key) != 16 )
  {
    return 'yms_err_addkey_invalid_key';
  }
  
  if ( strlen($otp) != 22 )
  {
    return 'yms_err_addkey_invalid_otp';
  }
  
  $otpdata = yms_decode_otp($otp, $key);
  if ( $otpdata === false )
  {
    return 'yms_err_addkey_invalid_otp';
  }
  if ( !$otpdata['crc_good'] )
  {
    return 'yms_err_addkey_crc_failed';
  }
  
  // make sure it's not already in there
  $q = $db->sql_query('SELECT 1 FROM ' . table_prefix . "yms_yubikeys WHERE public_id = '{$otpdata['publicid']}';");
  if ( !$q )
    $db->_die();
  
  if ( $db->numrows() > 0 )
  {
    $db->free_result();
    return 'yms_err_addkey_key_exists';
  }
  $db->free_result();
  
  $now = time();
  $key = yms_hex_encode($key);
  
  $flags = 0;
  if ( $enabled )
    $flags |= YMS_ENABLED;
  if ( $any_client )
    $flags |= YMS_ANY_CLIENT;
  
  $notes = $notes ? $db->escape(strval($notes)) : '';
  
  $q = $db->sql_query("INSERT INTO " . table_prefix . "yms_yubikeys(client_id, public_id, private_id, session_count, token_count, create_time, access_time, token_time, aes_secret, flags, notes) VALUES\n"
         . "  ($client_id, '{$otpdata['publicid']}', '{$otpdata['privateid']}', {$otpdata['session']}, {$otpdata['count']}, $now, $now, {$otpdata['timestamp']}, '$key', $flags, '$notes');");
  if ( !$q )
    $db->_die();
  
  return true;
}

function yms_chown_yubikey($otp, $client_id = false, $enabled = true, $any_client = false, $notes = false)
{
  global $db, $session, $paths, $template, $plugins; // Common objects
  
  if ( $client_id === false )
    $client_id = $GLOBALS['yms_client_id'];
  
  $otp = yms_tobinary($otp);
  
  if ( strlen($otp) != 22 )
  {
    return 'yms_err_addkey_invalid_otp';
  }
  
  $public_id = yms_hex_encode(substr($otp, 0, 6));
  
  // make sure it's already in there
  $q = $db->sql_query('SELECT id FROM ' . table_prefix . "yms_yubikeys WHERE public_id = '{$public_id}' AND client_id = 0;");
  if ( !$q )
    $db->_die();
  
  if ( $db->numrows() < 1 )
  {
    // this should never happen, as the OTP is put through validation before this function is called
    $db->free_result();
    return 'yms_err_claimkey_owner_invalid';
  }
  
  list($key_id) = $db->fetchrow_num();
  $db->free_result();
  
  $now = time();
  
  $flags = 0;
  if ( $enabled )
    $flags |= YMS_ENABLED;
  if ( $any_client )
    $flags |= YMS_ANY_CLIENT;
  
  $notes = $notes ? $db->escape(strval($notes)) : '';
  
  $q = $db->sql_query("UPDATE " . table_prefix . "yms_yubikeys SET flags = $flags, notes = '$notes', client_id = $client_id WHERE id = $key_id;");
  if ( !$q )
    $db->_die();
  
  return true;
}

function yms_delete_key($id, $client_id = false)
{
  global $db, $session, $paths, $template, $plugins; // Common objects
  
  if ( $client_id === false )
    $client_id = $GLOBALS['yms_client_id'];
  
  $q = $db->sql_query('SELECT 1 FROM ' . table_prefix . "yms_yubikeys WHERE id = $id AND client_id = $client_id;");
  if ( !$q )
    $db->_die();
  
  if ( $db->numrows() < 1 )
  {
    $db->free_result();
    return 'yms_err_delete_not_found';
  }
  $db->free_result();
  
  $q = $db->sql_query('DELETE FROM ' . table_prefix . "yms_yubikeys WHERE id = $id AND client_id = $client_id;");
  if ( !$q )
    $db->_die();
  
  return true;
}

function yms_validate_custom_field($value, $otp, $url)
{
  require_once(ENANO_ROOT . '/includes/http.php');
  $url = strtr($url, array(
      '%c' => rawurlencode($value),
      '%o' => rawurlencode($otp)
    ));
  // do we need to sign this?
  if ( strstr($url, '%h') && ($key = getConfig('yms_claim_auth_key', false)) )
  {
    list(, $signpart) = explode('?', $url);
    $signpart = preg_replace('/(&h=%h|^h=%h&)/', '', $signpart);
    $signpart = yms_ksort_url($signpart);
    
    $key = yms_tobinary($key);
    $key = yms_hex_encode($key);
    $hash = hmac_sha1($signpart, $key);
    $hash = yms_hex_decode($hash);
    $hash = base64_encode($hash);
    
    $url = str_replace('%h', rawurlencode($hash), $url);
  }
  
  // run authentication
  $result = yms_get_url($url);
  $result = yms_parse_auth_result($result, $key);
  
  if ( !$result['sig_valid'] )
    return 'yubiauth_err_response_bad_signature';
  
  if ( $result['status'] !== 'OK' )
  {
    if ( preg_match('/^[A-Z_]+$/', $result['status']) )
      return 'yubiauth_err_response_' . strtolower($result['status']);
    else
      return $result['status'];
  }
  
  // authentication is ok
  return true;
}

function yms_update_counters($id, $scount, $tcount, $client_id = false, $any_client = null)
{
  global $db, $session, $paths, $template, $plugins; // Common objects
  
  if ( !$client_id )
    $client_id = intval($GLOBALS['yms_client_id']);
  
  foreach ( array($id, $scount, $tcount, $client_id) as $var )
    if ( (!is_int($var) && !is_string($var)) || (is_string($var) && !ctype_digit($var)) )
      return "yms_err_expected_int";
    
  $any_client_sql = '';
  if ( is_bool($any_client) )
  {
    $operand = $any_client ? "|" : "& ~";
    $any_client_sql = ", flags = flags " . $operand . YMS_ANY_CLIENT;
  }
    
  $q = $db->sql_query('UPDATE ' . table_prefix . "yms_yubikeys SET session_count = {$scount}, token_count = {$tcount}{$any_client_sql} WHERE id = $id AND client_id = $client_id");
  if ( !$q )
    $db->_die();
  
  return true;
}

function yms_get_url($url)
{
  require_once(ENANO_ROOT . '/includes/http.php');
  
  $url = preg_replace('#^https?://#i', '', $url);
  if ( !preg_match('#^(\[?[a-z0-9-:]+(?:\.[a-z0-9-:]+\]?)*)(?::([0-9]+))?(/.*)$#U', $url, $match) )
  {
    return 'invalid_auth_url';
  }
  $server =& $match[1];
  $port = ( !empty($match[2]) ) ? intval($match[2]) : 80;
  $uri =& $match[3];
  try
  {
    $req = new Request_HTTP($server, $uri, 'GET', $port);
    $response = $req->get_response_body();
  }
  catch ( Exception $e )
  {
    return 'http_failed:' . $e->getMessage();
  }
  
  if ( $req->response_code !== HTTP_OK )
    return 'http_failed_status:' . $req->response_code;
  
  return $response;
}

function yms_parse_auth_result($result, $api_key = false)
{
  $result = explode("\n", trim($result));
  $arr = array();
  foreach ( $result as $line )
  {
    list($name) = explode('=', $line);
    $value = substr($line, strlen($name) + 1);
    $arr[$name] = $value;
  }
  // signature check
  if ( $api_key )
  {
    $signarr = $arr;
    ksort($signarr);
    unset($signarr['h']);
    $signpart = array();
    foreach ( $signarr as $name => $value )
      $signpart[] = "{$name}={$value}";
    
    $signpart = implode('&', $signpart);
    $api_key = yms_hex_encode(yms_tobinary($api_key));
    $right_sig = base64_encode(yms_hex_decode(
                   hmac_sha1($signpart, $api_key)
                 ));
    $arr['sig_valid'] = ( $arr['h'] === $right_sig );
  }
  else
  {
    $arr['sig_valid'] = true;
  }
  return $arr;
}

function yms_ksort_url($signpart)
{
  $arr = array();
  $values = explode('&', $signpart);
  foreach ( $values as $var )
  {
    list($name) = explode('=', $var);
    $value = substr($var, strlen($name) + 1);
    $arr[$name] = $value;
  }
  ksort($arr);
  $result = array();
  foreach ( $arr as $name => $value )
  {
    $result[] = "{$name}={$value}";
  }
  return implode('&', $result);
}

function yms_validate_otp($otp, $id)
{
  global $db, $session, $paths, $template, $plugins; // Common objects
  
  $public_id = yms_modhex_decode(substr($otp, 0, 12));
  if ( !$public_id )
  {
    return 'BAD_OTP';
  }
  // Just in case
  $public_id = $db->escape($public_id);
  
  $q = $db->sql_query("SELECT id, private_id, session_count, token_count, access_time, token_time, aes_secret, flags, client_id FROM " . table_prefix . "yms_yubikeys WHERE ( client_id = 0 or client_id = $id OR flags & " . YMS_ANY_CLIENT . " ) AND public_id = '$public_id';");
  if ( !$q )
    $db->_die();
  
  if ( $db->numrows($q) < 1 )
  {
    return 'NO_SUCH_KEY';
  }
  
  list($yubikey_id, $private_id, $session_count, $token_count, $access_time, $token_time, $aes_secret, $flags, $client_id) = $db->fetchrow_num($q);
  $session_count = intval($session_count);
  $token_count = intval($token_count);
  $access_time = intval($access_time);
  $token_time = intval($token_time);
  
  // check flags
  if ( $client_id > 0 )
  {
    if ( !($flags & YMS_ANY_CLIENT) )
    {
      return 'NO_SUCH_KEY';
    }
  }
  if ( !($flags & YMS_ENABLED) )
  {
    return 'NO_SUCH_KEY';
  }
  
  // decode the OTP
  $otp = yms_decode_otp($otp, $aes_secret);
  
  // check CRC
  if ( !$otp['crc_good'] )
  {
    return 'BAD_OTP';
  }
  
  // check private UID (avoids combining a whitelisted known public UID with the increment part of a malicious token)
  if ( $private_id !== $otp['privateid'] )
  {
    return 'BAD_OTP';
  }
  
  // check counters
  if ( $otp['session'] < $session_count )
  {
    return 'REPLAYED_OTP';
  }
  if ( $otp['session'] == $session_count && $otp['count'] <= $token_count )
  {
    return 'REPLAYED_OTP';
  }
  
  // update DB
  $q = $db->sql_query("UPDATE " . table_prefix . "yms_yubikeys SET session_count = {$otp['session']}, token_count = {$otp['count']}, access_time = " . time() . ", token_time = {$otp['timestamp']} WHERE id = $yubikey_id;");
  if ( !$q )
    $db->_die();
  
  // check timestamp
  if ( $otp['session'] == $session_count )
  {
    $expect_delta = time() - $access_time;
    // 8Hz Yubikey internal clock
    $actual_delta = intval(( $otp['timestamp'] - $token_time ) / 8);
    $fuzz = 150;
    if ( !yms_within($expect_delta, $actual_delta, $fuzz) )
    {
      // if we have a likely wraparound, just pass it
      if ( !($token_time > 0xe80000 && $otp['timestamp'] < 0x800000) )
      {
        return 'BAD_OTP';
      }
    }
    // $debug_array = array('ts_debug_delta_expected' => $expect_delta, 'ts_debug_delta_received' => $actual_delta);
  }
  
  // looks like we're good
  return 'OK';
}