yms/backend.php
author Dan Fuhry <dan@enanocms.org>
Wed, 11 Jan 2017 13:02:34 +0000
changeset 12 31387f4022e5
parent 4 9fdc988ce46e
child 13 8a8cdc21aa15
permissions -rw-r--r--
Tolerate up to 0.5Hz difference in OTP timestamps I've received complaints of OTP validation failures during the trial rollout at Datto. I dumped a few OTPs along with the times of validation and found that the user's Yubikey was running its oscillator at 8.32Hz. This commit adds tolerance for up to 0.5Hz of variation to the OTP's timestamp field.

<?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';
  }
  
  // check timestamp
  if ( $otp['session'] == $session_count )
  {
    $expect_delta = time() - $access_time;
    // Tolerate up to a 0.5Hz deviance from 8Hz. I've observed Yubikey
    // clocks running at 8.32Hz
    $actual_delta = $otp['timestamp'] - $token_time;
    $fuzz = 150 + round(($actual_delta / 7.5) - ($actual_delta / 8.5));
    // Now that we've calculated fuzz, convert the actual delta to quasi-seconds
    $actual_delta /= 8;
    if ( !yms_within($expect_delta, $actual_delta, $fuzz) )
    {
      // if we have a likely wraparound, just pass it
      if ( !($token_time > 0xe80000 && $otp['timestamp'] < 0x080000) )
      {
        return 'BAD_OTP';
      }
    }
    // $debug_array = array('ts_debug_delta_expected' => $expect_delta, 'ts_debug_delta_received' => $actual_delta);
  }
  
  // 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();
  
  // looks like we're good
  return 'OK';
}