First commit. Lacks key deletion support and an admin CP for controlling options.
authorDan
Fri, 31 Jul 2009 23:51:08 -0400
changeset 0 9997bee9ad03
child 1 765356a05643
First commit. Lacks key deletion support and an admin CP for controlling options.
YubikeyManagement.php
yms/backend.php
yms/cp.js
yms/icons/application_view_icons.png
yms/icons/key_add.png
yms/icons/key_delete.png
yms/icons/key_go.png
yms/icons/note.png
yms/icons/note_delete.png
yms/icons/show_client_info.png
yms/libotp.php
yms/styles.css
yms/transcode.php
yms/validate-functions.php
yms/validate.php
yms/yms.php
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/YubikeyManagement.php	Fri Jul 31 23:51:08 2009 -0400
@@ -0,0 +1,192 @@
+<?php
+/**!info**
+{
+  "Plugin Name"  : "Yubikey management service",
+  "Plugin URI"   : "http://enanocms.org/plugin/yubikey-yms",
+  "Description"  : "Adds the ability for Enano to act as a Yubikey authentication provider. The Yubikey authentication plugin is a prerequisite.",
+  "Author"       : "Dan Fuhry",
+  "Version"      : "0.1",
+  "Author URI"   : "http://enanocms.org/"
+}
+**!*/
+
+$plugins->attachHook('session_started', 'yms_add_special_pages();');
+
+function yms_add_special_pages()
+{
+  global $lang;
+  
+  register_special_page('YMS', 'yms_specialpage_yms');
+  register_special_page('YMSCreateClient', 'yms_specialpage_register');
+  register_special_page('YubikeyValidate', 'yms_specialpage_validate');
+}
+
+define('YMS_DISABLED', 0);
+define('YMS_ENABLED', 1);
+define('YMS_ANY_CLIENT', 2);
+
+define('YMS_INSTALLED', 1);
+
+require(ENANO_ROOT . '/plugins/yms/yms.php');
+require(ENANO_ROOT . '/plugins/yms/libotp.php');
+require(ENANO_ROOT . '/plugins/yms/transcode.php');
+require(ENANO_ROOT . '/plugins/yms/backend.php');
+require(ENANO_ROOT . '/plugins/yms/validate.php');
+require(ENANO_ROOT . '/plugins/yms/validate-functions.php');
+
+/**!language**
+
+The following text up to the closing comment tag is JSON language data.
+It is not PHP code but your editor or IDE may highlight it as such. This
+data is imported when the plugin is loaded for the first time; it provides
+the strings displayed by this plugin's interface.
+
+You should copy and paste this block when you create your own plugins so
+that these comments and the basic structure of the language data is
+preserved. All language data is in the same format as the Enano core
+language files in the /language/* directories. See the Enano Localization
+Guide and Enano API Documentation for further information on the format of
+language files.
+
+The exception in plugin language file format is that multiple languages
+may be specified in the language block. This should be done by way of making
+the top-level elements each a JSON language object, with elements named
+according to the ISO-639-1 language they are representing. The path should be:
+
+  root => language ID => categories array, ( strings object => category \
+  objects => strings )
+
+All text leading up to first curly brace is stripped by the parser; using
+a code tag makes jEdit and other editors do automatic indentation and
+syntax highlighting on the language data. The use of the code tag is not
+necessary; it is only included as a tool for development.
+
+<code>
+{
+  // english
+  eng: {
+    categories: [ 'meta', 'yms' ],
+    strings: {
+      meta: {
+        yms: 'Yubikey management system'
+      },
+      yms: {
+        specialpage_yms: 'Yubikey manager',
+        specialpage_register: 'Register YMS client',
+        specialpage_validate: 'Yubikey validation API',
+        err_yubikey_plugin_missing_title: 'Yubikey plugin not found',
+        err_yubikey_plugin_missing_body: 'The Yubikey YMS cannot load because the Enano <a href="http://enanocms.org/plugin/yubikey">Yubikey authentication plugin</a> is not installed. Please ask your administrator to install it.',
+        err_client_exists_title: 'Client already exists',
+        err_client_exists_body: 'You cannot register another YMS client using this same user account.',
+        register_confirm_title: 'Enable your account for Yubikey authentication',
+        register_confirm_body: 'As a Yubikey authentication client, you gain the ability to manage multiple Yubikeys and tie them to your own organization. It also lets you retrieve secret AES keys for tokens, register new or reprogrammed keys, validate Yubikey OTPs using your own API key, and deactivate keys in case of a compromise. Do you want to enable your account for Yubikey management?',
+        register_btn_submit: 'Create YMS client',
+        
+        register_msg_success_title: 'Congratulations! Your account is now enabled for YMS access.',
+        register_msg_success_body: '<p>You can now go to the <a href="%yms_link|htmlsafe%">YMS admin panel</a> and add your Yubikeys. Your client ID and API key are below:</p>
+                                      <p class="yms-copypara">Client ID: <span class="yms-copyfield">%client_id%</span><br />
+                                         API key: <span class="yms-copyfield">%api_key%</span><br />
+                                         Validation API URL: <span class="yms-copyfield">%validate_url%</span></p>
+                                    <p><b>Remember to secure your user account!</b> Your Enano login is used to administer your YMS account. For maximum security, use the Yubikey Settings page of the User Control Panel to require both a password and a Yubikey OTP to log in.</p>',
+        msg_no_yubikeys: 'No Yubikeys found',
+        btn_add_key: 'Add Yubikey',
+        btn_add_key_preregistered: 'Claim a New Key',
+        state_active: 'Active',
+        state_inactive: 'Inactive',
+        
+        th_id: 'ID#',
+        th_publicid: 'OTP prefix',
+        th_createtime: 'Created',
+        th_accesstime: 'Last accessed',
+        th_state: 'Lifecycle state',
+        th_note: 'Note',
+        
+        msg_access_never: 'Never',
+        
+        // Add key interface
+        lbl_addkey_heading: 'Register Yubikey',
+        lbl_addkey_desc: 'Register a Yubikey that you programmed yourself in YMS to enable validation of OTPs from that key against this server.',
+        lbl_addkey_field_secret: 'AES secret key:',
+        lbl_addkey_field_secret_hint: 'Input in ModHex, hex, or base-64. The format will be detected automatically.',
+        lbl_addkey_field_otp: 'Enter an OTP from this Yubikey:',
+        lbl_addkey_field_notes: 'Notes about this key:',
+        lbl_addkey_field_state: 'Lifecycle state:',
+        lbl_addkey_field_any_client_name: 'Allow validation by any client:',
+        lbl_addkey_field_any_client_hint: 'If unchecked, OTPs from this Yubikey can only be verified by someone using your client ID. Check this if you plan to use this Yubikey on websites you don\'t control.',
+        lbl_addkey_field_any_client: 'Other clients can validate OTPs from this key',
+        btn_addkey_submit: 'Register key',
+        msg_addkey_success: 'This key has been successfully registered.',
+        
+        err_addkey_crc_failed: 'The CRC check on the OTP failed. This usually means that your AES key is wrong or could not be properly interpreted.',
+        err_addkey_invalid_key: 'There was an error decoding your AES secret key. Please enter a 128-bit hex, ModHex, or base-64 value.',
+        err_addkey_invalid_otp: 'The OTP from the Yubikey is invalid.',
+        err_addkey_key_exists: 'This Yubikey is already registered on this server.',
+        
+        // Claim key interface
+        lbl_claimkey_heading: 'Claim Yubikey',
+        lbl_claimkey_desc: 'Attach a key you have not reprogrammed to your YMS account, so that you can see its AES secret key and keep track of it.',
+        lbl_claimkey_field_otp: 'Enter an OTP from this Yubikey:',
+        lbl_custom_hint: 'For your security, this is used to validate your ownership of this Yubikey.',
+        
+        // AES key view interface
+        showaes_th: 'AES secret key for key %public_id%',
+        showaes_lbl_hex: 'Hex:',
+        showaes_lbl_modhex: 'ModHex:',
+        showaes_lbl_base64: 'Base64:',
+        
+        // API key view interface
+        th_client_id: 'Client ID',
+        lbl_client_id: 'Client ID:',
+        th_api_key: 'API key',
+        
+        // Binary format converter
+        th_converted_value: 'Converted value',
+        conv_err_invalid_string: 'The string was invalid or you entered did not match the format you selected.',
+        th_converter: 'Convert binary formats',
+        conv_lbl_value: 'Value to convert:',
+        conv_lbl_format: 'Current encoding:',
+        conv_lbl_format_auto: 'Auto-detect',
+        conv_lbl_format_hex: 'Hexadecimal',
+        conv_lbl_format_modhex: 'ModHex',
+        conv_lbl_format_base64: 'Base-64',
+        conv_btn_submit: 'Convert',
+        
+        // Key list
+        btn_note_view: 'View or edit note',
+        btn_note_create: 'No note; click to create',
+        btn_show_aes: 'Show AES secret',
+        btn_show_converter: 'Binary encoding converter',
+        btn_show_client_info: 'View client info'
+      }
+    }
+  }
+}
+</code>
+**!*/
+
+/**!install dbms="mysql"; **
+
+CREATE TABLE {{TABLE_PREFIX}}yms_clients(
+  id int(12) NOT NULL DEFAULT 0,
+  apikey varchar(40) NOT NULL,
+  PRIMARY KEY ( id )
+);
+
+CREATE TABLE {{TABLE_PREFIX}}yms_yubikeys(
+  id int(12) NOT NULL auto_increment,
+  client_id int(12) NOT NULL DEFAULT 0,
+  public_id varchar(12) NOT NULL DEFAULT '000000000000',
+  private_id varchar(12) NOT NULL DEFAULT '000000000000',
+  session_count int(8) NOT NULL DEFAULT 0,
+  token_count int(8) NOT NULL DEFAULT 0,
+  create_time int(12) NOT NULL DEFAULT 0,
+  access_time int(12) NOT NULL DEFAULT 0,
+  token_time int(12) NOT NULL DEFAULT 0,
+  aes_secret varchar(40) NOT NULL DEFAULT '00000000000000000000000000000000',
+  flags int(8) NOT NULL DEFAULT 1,
+  notes text,
+  PRIMARY KEY (id)
+);
+
+**!*/
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/yms/backend.php	Fri Jul 31 23:51:08 2009 -0400
@@ -0,0 +1,325 @@
+<?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 = $session->user_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 = $session->user_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_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_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';
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/yms/cp.js	Fri Jul 31 23:51:08 2009 -0400
@@ -0,0 +1,269 @@
+function yms_showpage(page)
+{
+  load_component(['fadefilter', 'jquery', 'jquery-ui']);
+  yms_destroy_float();
+  
+  if ( aclDisableTransitionFX )
+    jQuery.fx.off = true;
+  
+  darken(true, 70, 'ymsmask');
+  
+  $('body').append('<div id="yms-float-wrapper"><div id="yms-float-body"><div id="yms-float-inner"><div class="yms-float-spinner theme-selector-spinner"></div></div></div></div>');
+  $('#yms-float-wrapper')
+    .css('top', String(getScrollOffset()) + 'px')
+    .css('left', 0)
+    .css('z-index', String( getHighestZ() + 20 ));
+    
+  ajaxGet(makeUrlNS('Special', 'YMS/' + page, 'noheaders'), function(ajax)
+    {
+      if ( ajax.readyState == 4 && ajax.status == 200 )
+      {
+        var fade_time = aclDisableTransitionFX ? 0 : 500;
+        $('#yms-float-body').animate({ width: 728, height: getHeight() - 200 });
+        $('.yms-float-spinner').fadeOut(fade_time, function()
+          {
+            $('#yms-float-inner')
+              .css('text-align', 'left')
+              .html(ajax.responseText)
+              .append('<div class="yms-float-closer"><a class="abutton abutton_green" href="#">' + $lang.get('etc_cancel') + '</a></div>');
+            $('.yms-float-closer a').click(function()
+              {
+                yms_destroy_float();
+                return false;
+              });
+            $('#yms-float-inner form').submit(yms_ajax_submit);
+            // focus first element in the form
+            $('#yms-float-inner input:first').focus();
+          });
+      }
+      else if ( ajax.readyState == 4 && ajax.status != 200 )
+      {
+        yms_destroy_float();
+      }
+    });
+}
+
+function yms_ajax_submit()
+{
+  var whitey = whiteOutElement(this);
+  
+  var qs = '';
+  $('input, select, textarea', this).each(function(i, e)
+    {
+      var name = $(e).attr('name');
+      var val = $(e).val();
+      
+      if ( $(e).attr('type') == 'checkbox' )
+      {
+        if ( !$(e).attr('checked') )
+          return;
+        val = 'on';
+      }
+      else if ( $(e).attr('type') == 'radio' )
+      {
+        if ( !$(e).attr('checked') )
+          return;
+      }
+      
+      if ( name )
+        qs += '&' + name + '=' + ajaxEscape(val);
+    });
+  qs = qs.replace(/^&/, '');
+  var submit_uri = $(this).attr('action');
+  var separator = (/\?/).test(submit_uri) ? '&' : '?';
+  submit_uri += separator + 'ajax&noheaders';
+  
+  var to_self = $(this).hasClass('submit_to_self');
+  ajaxPost(submit_uri, qs, function(ajax)
+    {
+      if ( ajax.readyState == 4 && ajax.status == 200 )
+      {
+        var response = String(ajax.responseText) + '';
+        if ( to_self )
+        {
+          // form submits to the dynamic frame, just set HTML and die
+          $(whitey).remove();
+          $('#yms-float-inner')
+            .html(response)
+            .append('<div class="yms-float-closer"><a class="abutton abutton_green" href="#">' + $lang.get('etc_cancel') + '</a></div>');
+          
+          $('.yms-float-closer a').click(function()
+            {
+              yms_destroy_float();
+              return false;
+            });
+          $('#yms-float-inner form').submit(yms_ajax_submit);
+          // focus first element in the form
+          $('#yms-float-inner input:first').focus();
+            
+          return true;
+        }
+        if ( !check_json_response(response) )
+        {
+          // invalid JSON, gracefully report error
+          whiteOutReportFailure(whitey);
+          setTimeout(function()
+            {
+              yms_destroy_float();
+              handle_invalid_json(response);
+            }, 1250);
+          return false;
+        }
+        response = parseJSON(response);
+        if ( response.mode == 'success' )
+        {
+          $('#yms-messages').html('<div class="info-box">' + $lang.get(response.message) + '</div>');
+          yms_refresh_keylist();
+          whiteOutReportSuccess(whitey);
+          setTimeout('yms_destroy_float();', 1250);
+        }
+        else if ( response.mode == 'error' )
+        {
+          whiteOutReportFailure(whitey);
+          setTimeout(function()
+            {
+              $('#yms-float-inner .error-box').remove();
+              $('#yms-float-inner').prepend('<div class="error-box">' + $lang.get(response.error) + '</div>');
+            }, 1250);
+        }
+      }
+      else if ( ajax.readyState == 4 && ajax.status != 200 )
+      {
+        whiteOutReportFailure(whitey);
+        setTimeout('yms_destroy_float();', 1250);
+      }
+    });
+  return false;
+}
+
+function yms_destroy_float()
+{
+  var fade_time = aclDisableTransitionFX ? 0 : 500;
+  $('#yms-float-wrapper').fadeOut(fade_time, function()
+    {
+      $('#yms-float-wrapper').remove();
+      enlighten(aclDisableTransitionFX, 'ymsmask');
+    });
+}
+
+function yms_refresh_keylist()
+{
+  $('#yms-keylist').empty();
+  ajaxGet(makeUrlNS('Special', 'YMS', 'noheaders&ajax'), function(ajax)
+    {
+      if ( ajax.readyState == 4 && ajax.status == 200 )
+      {
+        $('#yms-keylist').html(ajax.responseText);
+      }
+    });
+}
+
+function yms_toggle_state(span, id)
+{
+  // touch to put into closure scope
+  void(span);
+  var whitey = whiteOutElement(span.parentNode);
+  var newstate = $(span).hasClass('yms-disabled') ? 'active' : 'inactive';
+  ajaxPost(makeUrlNS('Special', 'YMS/AjaxToggleState'), 'id=' + id + '&state=' + newstate, function(ajax)
+    {
+      if ( ajax.readyState == 4 && ajax.status == 200 )
+      {
+        if ( ajax.responseText != 'ok' )
+        {
+          whiteOutReportFailure(whitey);
+          return false;
+        }
+        
+        whiteOutReportSuccess(whitey);
+        var newclass = newstate == 'active' ? 'yms-enabled' : 'yms-disabled';
+        var newtext  = newstate == 'active' ? 'yms_state_active' : 'yms_state_inactive';
+        $(span).removeClass('yms-disabled').removeClass('yms-enabled').addClass(newclass).text($lang.get(newtext));
+      }
+    });
+}
+
+function yms_show_notes(link, id)
+{
+  // show the box
+  var offset = $(link.parentNode).offset();
+  var height = $(link.parentNode).outerHeight();
+  var top = offset.top + height;
+  var left = ( offset.left + $(link.parentNode).outerWidth() ) - 420;
+  var box = document.createElement('div');
+  $(box)
+    .css('background-color', 'white')
+    .css('color', '#202020')
+    .css('padding', 10)
+    .css('position', 'absolute')
+    .css('width', 400)
+    .css('height', 130)
+    .css('top', top)
+    .css('left', left)
+    .appendTo('body');
+    
+  box.yk_id = id;
+  box.link = link;
+  
+  var whitey = whiteOutElement(box);
+  ajaxPost(makeUrlNS('Special', 'YMS/AjaxNotes'), 'get=' + id, function(ajax)
+    {
+      if ( ajax.readyState == 4 && ajax.status == 200 )
+      {
+        $(whitey).remove();
+        $(box).html('<p><textarea style="width: 400px; height: 80px;"></textarea></p><p><a class="abutton abutton_green save" style="font-weight: bold;"></a> <a class="abutton cancel"></a></p>');
+        $('textarea', box).val(ajax.responseText);
+        $('a.save', box).text($lang.get('etc_save_changes')).attr('href', '#').click(function()
+          {
+            var box = this.parentNode.parentNode;
+            var text = $('textarea:first', box).val();
+            yms_save_note(box, box.yk_id, text, box.link);
+            return false;
+          });
+        
+        $('a.cancel', box).text($lang.get('etc_cancel')).attr('href', '#').click(function()
+          {
+            $(this.parentNode.parentNode).remove();
+            return false;
+          });
+      }
+    });
+}
+
+function yms_save_note(box, id, text, link)
+{
+  var whitey = whiteOutElement(box);
+  void(link);
+  ajaxPost(makeUrlNS('Special', 'YMS/AjaxNotes'), 'save=' + id + '&note=' + ajaxEscape(text), function(ajax)
+    {
+      if ( ajax.readyState == 4 && ajax.status == 200 )
+      {
+        if ( ajax.responseText != 'ok' )
+        {
+          whiteOutReportFailure(whitey);
+          return false;
+        }
+        
+        var newsrc   = text == '' ? scriptPath + '/plugins/yms/icons/note_delete.png' : scriptPath + '/plugins/yms/icons/note.png';
+        var newtitle = text == '' ? $lang.get('yms_btn_note_create') : $lang.get('yms_btn_note_view');
+        $(link).attr('title', newtitle);
+        $('img:first', link).attr('src', newsrc);
+        
+        // remove any existing text
+        while ( link.nextSibling )
+          link.parentNode.removeChild(link.nextSibling);
+        
+        // insert text
+        if ( text != '' )
+        {
+          var summary = ' ' + (text.length > 15 ? text.substr(0, 12) + '...' : text);
+          link.parentNode.appendChild(document.createTextNode(summary));
+        }
+        
+        whiteOutReportSuccess(whitey);
+        setTimeout(function()
+          {
+            $(box).remove();
+          }, 1250);
+      }
+    });
+}
Binary file yms/icons/application_view_icons.png has changed
Binary file yms/icons/key_add.png has changed
Binary file yms/icons/key_delete.png has changed
Binary file yms/icons/key_go.png has changed
Binary file yms/icons/note.png has changed
Binary file yms/icons/note_delete.png has changed
Binary file yms/icons/show_client_info.png has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/yms/libotp.php	Fri Jul 31 23:51:08 2009 -0400
@@ -0,0 +1,84 @@
+<?php
+
+/**
+ * Returns OTP data. Numeric except for public and private IDs, which are hex.
+ * @return array Associative
+ */
+
+function yms_decode_otp($otp, $key)
+{
+  static $aes = false;
+  if ( !is_object($aes) )
+    $aes = AESCrypt::singleton(128, 128);
+  
+  $return = array();
+  
+  $otp = yms_tobinary($otp);
+  if ( strlen($otp) != 22 )
+  {
+    return false;
+  }
+  $key = yms_tobinary($key);
+  if ( strlen($key) != 16 )
+  {
+    return false;
+  }
+  
+  $cryptpart = yms_hex_encode(substr($otp, 6, 16));
+  $publicid = substr($otp, 0, 6);
+  
+  $return['publicid'] = yms_hex_encode($publicid);
+  $otp_decrypted = $aes->decrypt($cryptpart, $key, ENC_HEX);
+  $crc_is_good = yms_validate_crc($otp_decrypted);
+  $return['privateid'] = yms_hex_encode(substr($otp_decrypted, 0, 6));
+  $return['session'] = yms_unpack_int(strrev(substr($otp_decrypted, 6, 2)));
+  $return['timestamp'] = yms_unpack_int(strrev(substr($otp_decrypted, 8, 3)));
+  $return['count'] = yms_unpack_int(substr($otp_decrypted, 11, 1));
+  $return['random'] = yms_unpack_int(substr($otp_decrypted, 12, 2));
+  $return['crc'] = yms_unpack_int(substr($otp_decrypted, 14, 2));
+  $return['crc_good'] = $crc_is_good;
+  
+  return $return;
+}
+
+function yms_unpack_int($str)
+{
+  $return = 0;
+  for ( $i = 0; $i < strlen($str); $i++ )
+  {
+    $return = $return << 8;
+    $return = $return | ord($str{$i});
+  }
+  return $return;
+}
+
+function yms_crc16($buffer)
+{
+  $buffer = yms_tobinary($buffer);
+  
+  $m_crc=0x5af0;
+  for($bpos=0; $bpos<strlen($buffer); $bpos++)
+  {
+    $m_crc ^= ord($buffer[$bpos]);
+    for ($i=0; $i<8; $i++)
+    {
+      $j=$m_crc & 1;
+      $m_crc >>= 1;
+      if ($j) $m_crc ^= 0x8408;
+    }
+  }
+  return $m_crc;
+}
+
+function yms_validate_crc($token)
+{
+  $crc = yms_crc16($token);
+  return $crc == 0;
+}
+
+function yms_within($test, $control, $fuzz)
+{
+  $min = $control - $fuzz;
+  $max = $control + $fuzz;
+  return $test > $min && $test < $max;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/yms/styles.css	Fri Jul 31 23:51:08 2009 -0400
@@ -0,0 +1,56 @@
+p.yms-copypara {
+  line-height: 220%;
+  margin-left: 2.3em;
+}
+
+span.yms-copyfield {
+  padding: 3px 7px;
+  border: 1px dashed rgba(0, 0, 0, 0.2);
+  background-color: rgba(0, 0, 0, 0.05);
+}
+
+div.yms-buttons {
+  padding: 10px 0;
+  text-align: right;
+}
+
+div#yms-float-wrapper {
+  position: absolute;
+  width: 100%;
+  margin: 0;
+  padding: 0;
+  top: 0;
+  margin-top: 75px;
+}
+
+div#yms-float-body {
+  margin: 0 auto;
+  padding: 20px;
+  background-color: #ffffff;
+  text-align: center;
+  /* width: 708px; */
+  width: 130px;
+  height: 130px;
+  
+  clip: rect(0px, auto, auto, 0px);
+  overflow: auto;
+}
+
+div.yms-float-closer {
+  margin-top: 20px;
+  text-align: center;
+}
+
+span.yms-enabled {
+  color: white;
+  padding: 2px 4px;
+  background-color: #00aa00;
+  cursor: pointer;
+}
+
+span.yms-disabled {
+  color: white;
+  padding: 2px 4px;
+  background-color: #aa0000;
+  cursor: pointer;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/yms/transcode.php	Fri Jul 31 23:51:08 2009 -0400
@@ -0,0 +1,79 @@
+<?php
+
+define('CHARSET_HEX', '0123456789abcdef');
+define('CHARSET_MODHEX', 'cbdefghijklnrtuv');
+define('CHARSET_BASE64', 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=');
+
+function yms_modhex_encode($str)
+{
+  if ( !preg_match('/^[' . CHARSET_HEX . ']+$/', $str) )
+    $str = yms_hex_encode($str);
+  
+  return strtr($str, '0123456789abcdef', 'cbdefghijklnrtuv');
+}
+
+function yms_modhex_decode($str)
+{
+  if ( !preg_match('/^[' . CHARSET_MODHEX . ']+$/', $str) )
+    return false;
+  
+  return strtr($str, 'cbdefghijklnrtuv', '0123456789abcdef');
+}
+
+function yms_hex_decode($str)
+{
+  if ( !preg_match('/^[' . CHARSET_HEX . ']+$/', $str) )
+    return false;
+  
+  if ( strlen($str) % 2 != 0 )
+    return '';
+  
+  $return = '';
+  for ( $i = 0; $i < strlen($str); $i+=2 )
+  {
+    $chr = substr($str, $i, 2);
+    $return .= chr(intval(hexdec($chr)));
+  }
+  return $return;
+}
+
+function yms_hex_encode($str)
+{
+  $return = '';
+  for ( $i = 0; $i < strlen($str); $i++ )
+  {
+    $chr = dechex(ord($str{$i}));
+    if ( strlen($chr) < 2 )
+      $chr = "0$chr";
+    $return .= $chr;
+  }
+  return $return;
+}
+
+function yms_tobinary($str)
+{
+  if ( preg_match('/^[' . CHARSET_HEX . ']+$/', $str) )
+  {
+    return yms_hex_decode($str);
+  }
+  else if ( preg_match('/^[' . CHARSET_MODHEX . ']+$/', $str) )
+  {
+    return yms_hex_decode(yms_modhex_decode($str));
+  }
+  else if ( preg_match('#^[' . CHARSET_BASE64 . ']+$#', $str) )
+  {
+    return base64_decode($str);
+  }
+  return $str;
+}
+
+function yms_randbin($len)
+{
+  $ret = '';
+  for ( $i = 0; $i < $len; $i++ )
+  {
+    $ret .= chr(mt_rand(0, 255));
+  }
+  return $ret;
+}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/yms/validate-functions.php	Fri Jul 31 23:51:08 2009 -0400
@@ -0,0 +1,57 @@
+<?php
+
+function yms_send_reply($result, $api_key = '', $extra = array())
+{
+  header('Content-type: text/plain');
+  
+  global $g_api_key;
+  
+  if ( empty($api_key) )
+    $api_key = $g_api_key;
+  
+  if ( empty($api_key) )
+    $api_key = base64_encode("\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00");
+  
+  $now = gmdate("Y-m-d\TH:i:s");
+  echo yms_generate_signed_response(array_merge($extra, array(
+      't' => $now,
+      'status' => $result
+    )), $api_key);
+  
+  exit;
+}
+
+function yms_generate_signed_response($response, $api_key)
+{
+  $hash = yms_val_sign($response, $api_key);
+  $result = "h={$hash}\n";
+  foreach ( $response as $key => $value )
+  {
+    $result .= "{$key}={$value}\n";
+  }
+  return trim($result);
+}
+
+function yms_val_sign($response, $api_key)
+{
+  foreach ( array('h', 'title', 'auth') as $key )
+    if ( isset($response[$key]) )
+      unset($response[$key]);
+    
+  ksort($response);
+  
+  $signstr = array();
+  foreach ( $response as $key => $value )
+  {
+    $signstr[] = "$key=$value";
+  }
+  
+  $signstr = implode('&', $signstr);
+  
+  $api_key = yms_hex_encode(base64_decode($api_key));
+  $hash = hmac_sha1($signstr, $api_key);
+  $hash = yms_hex_decode($hash);
+  $hash = base64_encode($hash);
+  
+  return $hash;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/yms/validate.php	Fri Jul 31 23:51:08 2009 -0400
@@ -0,0 +1,47 @@
+<?php
+
+function page_Special_YubikeyValidate()
+{
+  global $db, $session, $paths, $template, $plugins; // Common objects
+  global $do_gzip;
+  $do_gzip = false;
+  
+  // Check parameters
+  if ( !isset($_GET['id']) )
+  {
+    yms_send_reply('MISSING_PARAMETER', '', array('info' => 'id'));
+  }
+  
+  if ( !isset($_GET['otp']) )
+  {
+    yms_send_reply('MISSING_PARAMETER', '', array('info' => 'otp'));
+  }
+  
+  // first, get API key so we can properly sign responses
+  $id = intval($_GET['id']);
+  $q = $db->sql_query("SELECT apikey FROM " . table_prefix . "yms_clients WHERE id = $id;");
+  if ( !$q )
+    $db->_die();
+  
+  if ( $db->numrows($q) < 1 )
+    yms_send_reply("NO_SUCH_CLIENT");
+  
+  list($g_api_key) = $db->fetchrow_num($q);
+  $db->free_result($q);
+  
+  // check API key
+  if ( isset($_GET['h']) )
+  {
+    $hex_api_key = yms_hex_encode(base64_decode($g_api_key));
+    $right_sig = yubikey_sign($_GET, $hex_api_key);
+    if ( $right_sig !== $_GET['h'] )
+    {
+      yms_send_reply('BAD_SIGNATURE');
+    }
+  }
+  
+  $GLOBALS['g_api_key'] =& $g_api_key;
+  
+  yms_send_reply(yms_validate_otp($_GET['otp'], $id));
+}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/yms/yms.php	Fri Jul 31 23:51:08 2009 -0400
@@ -0,0 +1,842 @@
+<?php
+
+function page_Special_YMS()
+{
+  global $db, $session, $paths, $template, $plugins; // Common objects
+  global $lang;
+  global $output;
+  
+  // Require re-auth?
+  if ( $session->auth_level < USER_LEVEL_CHPREF && getConfig('yms_require_reauth', 1) == 1 )
+  {
+    redirect(makeUrlNS('Special', "Login/$paths->fullpage", 'level=' . USER_LEVEL_CHPREF), '', '', 0);
+  }
+  
+  // Check for Yubikey plugin
+  if ( !function_exists('yubikey_validate_otp') )
+  {
+    die_friendly($lang->get('yms_err_yubikey_plugin_missing_title'), '<p>' . $lang->get('yms_err_yubikey_plugin_missing_body') . '</p>');
+  }
+  
+  // Does the client exist?
+  $q = $db->sql_query('SELECT 1 FROM ' . table_prefix . "yms_clients WHERE id = {$session->user_id};");
+  if ( !$q )
+    $db->_die();
+  
+  $client_exists = $db->numrows();
+  $db->free_result();
+  if ( !$client_exists )
+  {
+    redirect(makeUrlNS('Special', 'YMSCreateClient'), '', '', 0);
+  }
+  
+  // Check for a subpage request
+  if ( $subpage = $paths->getParam(0) )
+  {
+    if ( preg_match('/^[A-z0-9]+$/', $subpage) )
+    {
+      if ( function_exists("page_Special_YMS_{$subpage}") )
+      {
+        // call the subpage
+        return call_user_func("page_Special_YMS_{$subpage}");
+      }
+    }
+  }
+  
+  //
+  // POST processing
+  //
+  
+  if ( isset($_POST['add_aes']) && isset($_POST['add_otp']) )
+  {
+    $client_id = false;
+    $enabled = $_POST['state'] == 'active';
+    $any_client = isset($_POST['any_client']);
+    $notes = $_POST['notes'];
+    $result = yms_add_yubikey($_POST['add_aes'], $_POST['add_otp'], $client_id, $enabled, $any_client, $notes);
+    yms_send_response('yms_msg_addkey_success', $result);
+  }
+  else if ( isset($_POST['claim_otp']) )
+  {
+    // do we need to validate a custom field?
+    if ( ($url = getConfig('yms_claim_auth_url')) && getConfig('yms_claim_auth_field') )
+    {
+      if ( ($result = yms_validate_custom_field($_POST['custom_field'], $_POST['claim_otp'], $url)) !== true )
+        yms_send_response('n/a', $result);
+    }
+    
+    // validate this OTP, make sure it's all good
+    $result = strtolower(yms_validate_otp($_POST['claim_otp'], 0));
+    if ( $result !== 'ok' )
+      yms_send_response('n/a', "yubiauth_err_response_{$result}");
+    
+    // change owner
+    $client_id = false;
+    $enabled = $_POST['state'] == 'active';
+    $any_client = isset($_POST['any_client']);
+    $notes = $_POST['notes'];
+    $result = yms_chown_yubikey($_POST['claim_otp'], $client_id, $enabled, $any_client, $notes);
+    yms_send_response('yms_msg_addkey_success', $result);
+  }
+  
+  // Preload JS libraries we need for Yubikey
+  $template->preload_js(array('jquery', 'jquery-ui', 'l10n', 'flyin', 'messagebox', 'fadefilter'));
+  // Load CSS
+  $template->add_header('<link rel="stylesheet" type="text/css" href="' . scriptPath . '/plugins/yms/styles.css" />');
+  // Load JS
+  $template->add_header('<script type="text/javascript" src="' . scriptPath . '/plugins/yms/cp.js"></script>');
+  
+  // Send header
+  $output->header();
+  
+  // Message container
+  if ( !isset($_GET['ajax'] ) )
+    echo '<div id="yms-messages"></div><div id="yms-keylist">';
+  
+  // Buttons
+  ?>
+  <div class="yms-buttons">
+    <a class="abutton abutton_green icon" style="background-image: url(<?php echo scriptPath; ?>/plugins/yms/icons/key_add.png);"
+       href="<?php echo makeUrlNS('Special', 'YMS/AddKey'); ?>" onclick="yms_showpage('AddKey'); return false;">
+      <?php echo $lang->get('yms_btn_add_key'); ?>
+    </a>
+    <a class="abutton abutton_blue icon" style="background-image: url(<?php echo scriptPath; ?>/plugins/yms/icons/key_add.png);"
+       href="<?php echo makeUrlNS('Special', 'YMS/AddPreregisteredKey'); ?>" onclick="yms_showpage('AddPreregisteredKey'); return false;">
+      <?php echo $lang->get('yms_btn_add_key_preregistered'); ?>
+    </a>
+  </div>
+  <?php
+  
+  // Pull all Yubikeys
+  $q = $db->sql_query('SELECT id, public_id, session_count, create_time, access_time, flags, notes FROM ' . table_prefix . "yms_yubikeys WHERE client_id = {$session->user_id};");
+  if ( !$q )
+    $db->_die();
+  
+  if ( $db->numrows() < 1 )
+  {
+    echo '<h2 class="emptymessage">' . $lang->get('yms_msg_no_yubikeys') . '</h2>';
+  }
+  else
+  {
+    ?>
+    <div class="tblholder">
+    <table border="0" cellspacing="1" cellpadding="4">
+    
+    <!-- Table header -->
+      <tr>
+        <th><?php echo $lang->get('yms_th_id'); ?></th>
+        <th><?php echo $lang->get('yms_th_publicid'); ?></th>
+        <th><?php echo $lang->get('yms_th_createtime'); ?></th>
+        <th><?php echo $lang->get('yms_th_accesstime'); ?></th>
+        <th><?php echo $lang->get('yms_th_state'); ?></th>
+        <th><?php echo $lang->get('yms_th_note'); ?></th>
+        <th></th>
+      </tr>
+    
+    <?php
+      $cls = 'row2';
+      while ( $row = $db->fetchrow($q) )
+      {
+        $cls = $cls == 'row2' ? 'row1' : 'row2';
+        ?>
+        <tr>
+          <!-- Key ID -->
+          <td style="text-align: center;" class="<?php echo $cls; ?>"><?php echo $row['id']; ?></td>
+          
+          <!-- Public UID -->
+          <td style="text-align: left;" class="<?php echo $cls; ?>"><?php echo yms_modhex_encode($row['public_id']); ?></td>
+          
+          <!-- Create time -->
+          <td style="text-align: left;" class="<?php echo $cls; ?>"><?php echo yms_date($row['create_time']); ?></td>
+          
+          <!-- Access time -->
+          <td style="text-align: left;" class="<?php echo $cls; ?>"><?php echo $row['access_time'] <= $row['create_time'] ? $lang->get('yms_msg_access_never') : yms_date($row['access_time']); ?></td>
+          
+          <!-- State -->
+          <td style="text-align: center;" class="<?php echo $cls; ?>"><?php echo yms_state_indicator($row['flags'], $row['id']); ?></td>
+          
+          <!-- Notes -->
+          <td style="text-align: center;" class="<?php echo $cls; ?>"><?php echo yms_notes_cell($row['notes'], $row['id']); ?></td>
+          
+          <!-- Actions -->
+          <td style="text-align: center;" class="<?php echo $cls; ?>"><?php echo yms_show_actions($row);  ?></td>
+        </tr>
+        <?php
+      }
+    ?>
+    
+    </table>
+    </div>
+    
+    <br /><br />
+    <a href="<?php echo makeUrlNS('Special', 'YMS/Converter'); ?>" onclick="yms_showpage('Converter'); return false;" class="abutton abutton_red icon"
+       style="background-image: url(<?php echo scriptPath; ?>/plugins/yms/icons/application_view_icons.png);">
+      <?php echo $lang->get('yms_btn_show_converter'); ?>
+    </a>
+    
+    <a href="<?php echo makeUrlNS('Special', 'YMS/Converter'); ?>" onclick="yms_showpage('ShowClientInfo'); return false;" class="abutton abutton_blue icon"
+       style="background-image: url(<?php echo scriptPath; ?>/plugins/yms/icons/show_client_info.png);">
+      <?php echo $lang->get('yms_btn_show_client_info'); ?>
+    </a>
+    
+    <?php
+  }
+  $db->free_result($q);
+  
+  // close off inner div (yms-keylist)
+  if ( !isset($_GET['ajax'] ) )
+    echo '</div>';
+  
+  // Send footer
+  $output->footer();
+}
+
+// Add key, using AES secret
+function page_Special_YMS_AddKey()
+{
+  global $output;
+  global $lang;
+  
+  $output->header();
+  ?>
+  <h3><?php echo $lang->get('yms_lbl_addkey_heading'); ?></h3>
+  <p><?php echo $lang->get('yms_lbl_addkey_desc'); ?></p>
+  <form action="<?php echo makeUrlNS('Special', 'YMS'); ?>" method="post">
+  
+    <div class="tblholder">
+    <table border="0" cellspacing="1" cellspacing="4">
+    
+      <!-- AES secret -->
+      <tr>
+        <td class="row2">
+          <?php echo $lang->get('yms_lbl_addkey_field_secret'); ?><br />
+          <small><?php echo $lang->get('yms_lbl_addkey_field_secret_hint'); ?></small>
+        </td>
+        <td class="row1">
+          <input type="text" name="add_aes" value="" size="40" />
+        </td>
+      </tr>
+      
+      <!-- OTP -->
+      <tr>
+        <td class="row2">
+          <?php echo $lang->get('yms_lbl_addkey_field_otp'); ?>
+        </td>
+        <td class="row1">
+          <?php echo generate_yubikey_field('add_otp'); ?>
+        </td>
+      </tr>
+      
+      <!-- State -->
+      <tr>
+        <td class="row2">
+          <?php echo $lang->get('yms_lbl_addkey_field_state'); ?>
+        </td>
+        <td class="row1">
+          <select name="state">
+            <option value="active" selected="selected"><?php echo $lang->get('yms_state_active'); ?></option>
+            <option value="inactive"><?php echo $lang->get('yms_state_inactive'); ?></option>
+          </select>
+        </td>
+      </tr>
+      
+      <!-- Any client -->
+      <tr>
+        <td class="row2">
+          <?php echo $lang->get('yms_lbl_addkey_field_any_client_name'); ?><br />
+          <small><?php echo $lang->get('yms_lbl_addkey_field_any_client_hint'); ?></small>
+        </td>
+        <td class="row1">
+          <label>
+            <input type="checkbox" name="any_client" />
+            <?php echo $lang->get('yms_lbl_addkey_field_any_client'); ?>
+          </label>
+        </td>
+      </tr>
+      
+      <!-- Notes -->
+      <tr>
+        <td class="row2">
+          <?php echo $lang->get('yms_lbl_addkey_field_notes'); ?>
+        </td>
+        <td class="row1">
+          <textarea style="font-family: sans-serif; font-size: 9pt;" name="notes" rows="5" cols="40"></textarea>
+        </td>
+      </tr>
+      
+      <!-- Submit -->
+      <tr>
+        <th class="subhead" colspan="2">
+          <input type="submit" value="<?php echo $lang->get('yms_btn_addkey_submit'); ?>" />
+        </th>
+      </tr>
+      
+    </table>
+    </div>
+  
+  </form>
+  <?php
+  $output->footer();
+}
+
+// Add key that's already registered
+function page_Special_YMS_AddPreregisteredKey()
+{
+  global $db, $session, $paths, $template, $plugins; // Common objects
+  global $lang, $output;
+  
+  $output->header();
+  ?>
+  <h3><?php echo $lang->get('yms_lbl_claimkey_heading'); ?></h3>
+  <p><?php echo $lang->get('yms_lbl_claimkey_desc'); ?></p>
+  <form action="<?php echo makeUrlNS('Special', 'YMS'); ?>" method="post">
+  
+    <div class="tblholder">
+    <table border="0" cellspacing="1" cellspacing="4">
+    
+      <!-- OTP -->
+      <tr>
+        <td class="row2">
+          <?php echo $lang->get('yms_lbl_addkey_field_otp'); ?>
+        </td>
+        <td class="row1">
+          <?php echo generate_yubikey_field('claim_otp'); ?>
+        </td>
+      </tr>
+      
+      <!-- State -->
+      <tr>
+        <td class="row2">
+          <?php echo $lang->get('yms_lbl_addkey_field_state'); ?>
+        </td>
+        <td class="row1">
+          <select name="state">
+            <option value="active" selected="selected"><?php echo $lang->get('yms_state_active'); ?></option>
+            <option value="inactive"><?php echo $lang->get('yms_state_inactive'); ?></option>
+          </select>
+        </td>
+      </tr>
+      
+      <!-- Any client -->
+      <tr>
+        <td class="row2">
+          <?php echo $lang->get('yms_lbl_addkey_field_any_client_name'); ?><br />
+          <small><?php echo $lang->get('yms_lbl_addkey_field_any_client_hint'); ?></small>
+        </td>
+        <td class="row1">
+          <label>
+            <input type="checkbox" name="any_client" />
+            <?php echo $lang->get('yms_lbl_addkey_field_any_client'); ?>
+          </label>
+        </td>
+      </tr>
+      
+      <!-- Notes -->
+      <tr>
+        <td class="row2">
+          <?php echo $lang->get('yms_lbl_addkey_field_notes'); ?>
+        </td>
+        <td class="row1">
+          <textarea style="font-family: sans-serif; font-size: 9pt;" name="notes" rows="5" cols="40"></textarea>
+        </td>
+      </tr>
+      
+      <?php if ( ($field = getConfig('yms_claim_auth_field', '')) && getConfig('yms_claim_auth_url') ): ?>
+      <!-- Custom field -->
+      <tr>
+        <td class="row2">
+          <?php echo htmlspecialchars($field); ?>
+        </td>
+        <td class="row1">
+          <input type="text" name="custom_field" value="" size="30" />
+        </td>
+      </tr>
+      <?php endif; ?>
+      
+      <!-- Submit -->
+      <tr>
+        <th class="subhead" colspan="2">
+          <input type="submit" value="<?php echo $lang->get('yms_btn_addkey_submit'); ?>" />
+        </th>
+      </tr>
+      
+    </table>
+    </div>
+  
+  </form>
+  <?php
+  $output->footer();
+}
+
+// Show the AES secret for a key
+function page_Special_YMS_ShowAESKey()
+{
+  global $db, $session, $paths, $template, $plugins; // Common objects
+  global $lang, $output;
+  
+  $id = intval($paths->getParam(1));
+  
+  // verify ownership, retrieve key
+  $q = $db->sql_query('SELECT client_id, public_id, aes_secret FROM ' . table_prefix . "yms_yubikeys WHERE id = $id;");
+  if ( !$q )
+    $db->_die();
+  
+  if ( $db->numrows() < 1 )
+  {
+    die_friendly('no rows', '<p>key not found</p>');
+  }
+  
+  list($client_id, $public_id, $secret) = $db->fetchrow_num();
+  $db->free_result();
+  
+  if ( $client_id !== $session->user_id )
+    die_friendly($lang->get('etc_access_denied_short'), '<p>' . $lang->get('etc_access_denied') . '</p>');
+  
+  $output->header();
+  ?>
+  <div class="tblholder">
+  <table border="0" cellspacing="1" cellpadding="4">
+    <tr>
+      <th colspan="2">
+      <?php echo $lang->get('yms_showaes_th', array('public_id' => yms_modhex_encode($public_id))); ?>
+      </th>
+    </tr>
+    
+    <!-- hex -->
+    <tr>
+      <td class="row2" style="width: 50%;">
+        <?php echo $lang->get('yms_showaes_lbl_hex'); ?>
+      </td>
+      <td class="row1">
+        <?php echo $secret; ?>
+      </td>
+    </tr>
+    
+    <!-- modhex -->
+    <tr>
+      <td class="row2">
+        <?php echo $lang->get('yms_showaes_lbl_modhex'); ?>
+      </td>
+      <td class="row1">
+        <?php echo yms_modhex_encode($secret); ?>
+      </td>
+    </tr>
+    
+    <!-- base64 -->
+    <tr>
+      <td class="row2">
+        <?php echo $lang->get('yms_showaes_lbl_base64'); ?>
+      </td>
+      <td class="row1">
+        <?php echo base64_encode(yms_tobinary($secret)); ?>
+      </td>
+    </tr>
+    
+  </table>
+  </div>
+  <?php
+  $output->footer();
+}
+
+// show the user's API key and client ID
+function page_Special_YMS_ShowClientInfo()
+{
+  global $db, $session, $paths, $template, $plugins; // Common objects
+  global $lang, $output;
+  
+  $q = $db->sql_query('SELECT apikey FROM ' . table_prefix . "yms_clients WHERE id = {$session->user_id};");
+  if ( !$q )
+    $db->_die();
+  
+  list($api_key) = $db->fetchrow_num();
+  $db->free_result();
+  
+  $api_key = yms_tobinary($api_key);
+  
+  $output->header();
+  ?>
+  <div class="tblholder">
+  <table border="0" cellspacing="1" cellpadding="4">
+  
+    <tr>
+      <th colspan="2"><?php echo $lang->get('yms_th_client_id'); ?></th>
+    </tr>
+    
+    <tr>
+      <td class="row2"><?php echo $lang->get('yms_lbl_client_id'); ?></td>
+      <td class="row1"><?php echo strval($session->user_id); ?></td>
+    </tr>
+    
+    <tr>
+      <th colspan="2"><?php echo $lang->get('yms_th_api_key'); ?></th>
+    </tr>
+    
+    <tr>
+      <td class="row2"><?php echo $lang->get('yms_showaes_lbl_hex'); ?></td>
+      <td class="row1"><?php echo yms_hex_encode($api_key); ?></td>
+    </tr>
+    
+    <tr>
+      <td class="row2"><?php echo $lang->get('yms_showaes_lbl_modhex'); ?></td>
+      <td class="row1"><?php echo yms_modhex_encode($api_key); ?></td>
+    </tr>
+    
+    <tr>
+      <td class="row2"><?php echo $lang->get('yms_showaes_lbl_base64'); ?></td>
+      <td class="row1"><?php echo base64_encode($api_key); ?></td>
+    </tr>
+  
+  </table>
+  </div>
+  <?php
+  $output->footer();
+}
+
+// Converter between different binary encodings
+function page_Special_YMS_Converter()
+{
+  global $db, $session, $paths, $template, $plugins; // Common objects
+  global $lang, $output;
+  
+  $output->header();
+  
+  if ( isset($_POST['value']) )
+  {
+    switch($_POST['format'])
+    {
+      case 'auto':
+      default:
+        $binary = yms_tobinary($_POST['value']);
+        break;
+      case 'hex':
+        $_POST['value'] = str_replace(" ", '', $_POST['value']);
+        $binary = yms_hex_decode($_POST['value']);
+        break;
+      case 'modhex':
+        $binary = yms_hex_decode(yms_modhex_decode($_POST['value']));
+        break;
+      case 'base64':
+        $binary = base64_decode($_POST['value']);
+        break;
+    }
+    
+    if ( empty($binary) )
+    {
+      echo '<div class="error-box">' . $lang->get('yms_conv_err_invalid_string') . '</div>';
+    }
+    else
+    {
+    ?>
+    <div class="tblholder">
+    <table border="0" cellspacing="1" cellpadding="4">
+    
+      <tr>
+        <th colspan="2"><?php echo $lang->get('yms_th_converted_value'); ?></th>
+      </tr>
+      
+      <tr>
+        <td class="row2"><?php echo $lang->get('yms_showaes_lbl_hex'); ?></td>
+        <td class="row1"><?php echo yms_hex_encode($binary); ?></td>
+      </tr>
+      
+      <tr>
+        <td class="row2"><?php echo $lang->get('yms_showaes_lbl_modhex'); ?></td>
+        <td class="row1"><?php echo yms_modhex_encode($binary); ?></td>
+      </tr>
+      
+      <tr>
+        <td class="row2"><?php echo $lang->get('yms_showaes_lbl_base64'); ?></td>
+        <td class="row1"><?php echo base64_encode($binary); ?></td>
+      </tr>
+    
+    </table>
+    </div>
+    <?php
+    }
+  }
+  
+  ?>
+  <form method="post" class="submit_to_self" action="<?php echo makeUrl($paths->fullpage); ?>">
+  
+  <div class="tblholder">
+  <table border="0" cellspacing="1" cellpadding="4">
+  
+    <tr>
+      <th colspan="2"><?php echo $lang->get('yms_th_converter'); ?></th>
+    </tr>
+    
+    <tr>
+      <td class="row2" style="width: 30%;"><?php echo $lang->get('yms_conv_lbl_value'); ?></td>
+      <td class="row1"><input type="text" name="value" size="60" /></td>
+    </tr>
+    
+    <tr>
+      <td class="row2" style="width: 30%;"><?php echo $lang->get('yms_conv_lbl_format'); ?></td>
+      <td class="row1">
+        <?php
+        foreach ( array('auto', 'hex', 'modhex', 'base64') as $i => $fmt )
+        {
+          echo '<label><input type="radio" name="format" value="' . $fmt . '" ';
+          if ( ( isset($_POST['format']) && $_POST['format'] === $fmt ) || ( !isset($_POST['format']) && $i == 0 ) )
+            echo 'checked="checked" ';
+          
+          echo '/> ';
+          echo $lang->get("yms_conv_lbl_format_$fmt"); 
+          echo "</label>\n        ";
+        }
+        ?>
+      </td>
+    </tr>
+    
+    <tr>
+      <th class="subhead" colspan="2">
+        <input type="submit" value="<?php echo $lang->get('yms_conv_btn_submit'); ?>" />
+      </th>
+    </tr>
+  
+  </table>
+  </div>
+  
+  </form>
+  <?php
+  
+  $output->footer();
+}
+
+function page_Special_YMS_AjaxToggleState()
+{
+  global $db, $session, $paths, $template, $plugins; // Common objects
+  
+  $id = intval($_POST['id']);
+  if ( $_POST['state'] === 'active' )
+    $expr = 'flags | ' . YMS_ENABLED;
+  else
+    $expr = 'flags & ~' . YMS_ENABLED;
+    
+  $q = $db->sql_query('UPDATE ' . table_prefix . "yms_yubikeys SET flags = $expr WHERE id = $id AND client_id = {$session->user_id};");
+  if ( !$q )
+    $db->die_json();
+  
+  if ( $db->sql_affectedrows() < 1 )
+    echo 'no affected rows; not ';
+  
+  echo 'ok';
+}
+
+function page_Special_YMS_AjaxNotes()
+{
+  global $db, $session, $paths, $template, $plugins; // Common objects
+  
+  if ( isset($_POST['get']) )
+  {
+    $id = intval($_POST['get']);
+    $q = $db->sql_query('SELECT notes FROM ' . table_prefix . "yms_yubikeys WHERE id = $id AND client_id = {$session->user_id};");
+    if ( !$q )
+      $db->_die();
+    if ( $db->numrows() < 1 )
+    {
+      echo "key not found";
+    }
+    else
+    {
+      list($note) = $db->fetchrow_num();
+      echo $note;
+    }
+    $db->free_result();
+  }
+  else if ( isset($_POST['save']) )
+  {
+    $id = intval($_POST['save']);
+    $note = trim($_POST['note']);
+    $note = $db->escape($note);
+    $q = $db->sql_query('UPDATE ' . table_prefix . "yms_yubikeys SET notes = '$note' WHERE id = $id AND client_id = {$session->user_id};");
+    if ( !$q )
+      $db->die_json();
+    
+    echo 'ok';
+  }
+}
+
+// Add key, using just an OTP
+// Requires the key to be in the database as client ID 0
+
+// Client creation
+function page_Special_YMSCreateClient()
+{
+  global $db, $session, $paths, $template, $plugins; // Common objects
+  global $lang;
+  global $output;
+  
+  // Require re-auth?
+  if ( $session->auth_level < USER_LEVEL_CHPREF && getConfig('yms_require_reauth', 1) == 1 )
+  {
+    redirect(makeUrlNS('Special', "Login/$paths->fullpage", 'level=' . USER_LEVEL_CHPREF), '', '', 0);
+  }
+  
+  // Check for Yubikey plugin
+  if ( !function_exists('yubikey_validate_otp') )
+  {
+    die_friendly($lang->get('yms_err_yubikey_plugin_missing_title'), '<p>' . $lang->get('yms_err_yubikey_plugin_missing_body') . '</p>');
+  }
+  
+  // Does the client exist?
+  $q = $db->sql_query('SELECT 1 FROM ' . table_prefix . "yms_clients WHERE id = {$session->user_id};");
+  if ( !$q )
+    $db->_die();
+  
+  $client_exists = $db->numrows();
+  $db->free_result();
+  
+  if ( $client_exists )
+  {
+    die_friendly($lang->get('yms_err_client_exists_title'), '<p>' . $lang->get('yms_err_client_exists_body') . '</p>');
+  }
+  
+  $template->add_header('<link rel="stylesheet" type="text/css" href="' . scriptPath . '/plugins/yms/styles.css" />');
+  $output->header();
+  
+  if ( isset($_POST['register_client']) )
+  {
+    // register the client
+    // SHA1 key length: 160 bits
+    $api_key = base64_encode(AESCrypt::randkey(160 / 8));
+    $client_id = $session->user_id;
+    
+    $q = $db->sql_query('INSERT INTO ' . table_prefix . "yms_clients(id, apikey) VALUES ($client_id, '$api_key');");
+    if ( !$q )
+      $db->_die();
+    
+    $validate_url = makeUrlComplete('Special', 'YubikeyValidate');
+    $validate_url = preg_replace('/[?&]auth=[0-9a-f]+/', '', $validate_url);
+    
+    ?>
+    <h3><?php echo $lang->get('yms_register_msg_success_title'); ?></h3>
+    <?php echo $lang->get('yms_register_msg_success_body', array(
+        'yms_link' => makeUrlNS('Special', 'YMS'),
+        'client_id' => $client_id,
+        'api_key' => $api_key,
+        'validate_url' => $validate_url
+      ));
+  }
+  else
+  {
+    // confirmation page
+    ?>
+    <form action="<?php echo makeUrlNS('Special', 'YMSCreateClient'); ?>" method="post">
+      <h3><?php echo $lang->get('yms_register_confirm_title'); ?></h3>
+      <p><?php echo $lang->get('yms_register_confirm_body'); ?></p>
+      <p>
+        <input type="submit" style="font-weight: bold;" name="register_client" value="<?php echo $lang->get('yms_register_btn_submit'); ?>" />
+        <input type="submit" name="cancel" value="<?php echo $lang->get('etc_cancel'); ?>" />
+      </p>
+    </form>
+    <?php
+  }
+  
+  $output->footer();
+}
+
+// Generic response function
+// Processing functions return either true or a string containing an error message. This
+// takes that return, and sends a response through the appropriate channel, while allowing
+// shared backend functions.
+
+function yms_send_response($success_string, $result)
+{
+  global $lang, $output;
+  
+  if ( $result === true )
+  {
+    if ( isset($_GET['ajax']) )
+    {
+      yms_json_response(array(
+        'mode' => 'success',
+        'message' => $lang->get($success_string)
+      ));
+    }
+    else
+    {
+      $output->add_after_header(
+          '<div class="info-box">' . $lang->get($success_string) . '</div>'
+        );
+    }
+  }
+  else
+  {
+    if ( isset($_GET['ajax']) )
+    {
+      yms_json_response(array(
+        'mode' => 'error',
+        'error' => $lang->get($result)
+      ));
+    }
+    else
+    {
+      $output->add_after_header(
+          '<div class="error-box">' . $lang->get($result) . '</div>'
+        );
+    }
+  }
+}
+
+function yms_json_response($response)
+{
+  global $db, $session, $paths, $template, $plugins; // Common objects
+  
+  header('Content-type: application/json');
+  echo enano_json_encode($response);
+  
+  $db->close();
+  exit;
+}
+
+function yms_date($ts)
+{
+  return enano_date('Y-m-d H:m:i', $ts);
+}
+
+function yms_state_indicator($flags, $id)
+{
+  global $lang;
+  return $flags & YMS_ENABLED ?
+    '<span onclick="yms_toggle_state(this, ' . $id . ');" class="yms-enabled">' . $lang->get('yms_state_active') . '</span>' :
+    '<span onclick="yms_toggle_state(this, ' . $id . ');" class="yms-disabled">' . $lang->get('yms_state_inactive') . '</span>';
+}
+
+function yms_notes_cell($notes, $id)
+{
+  global $lang;
+  $notes = trim($notes);
+  if ( empty($notes) )
+  {
+    $img = 'note_delete.png';
+    $str = $lang->get('yms_btn_note_create');
+  }
+  else
+  {
+    $img = 'note.png';
+    $str = $lang->get('yms_btn_note_view');
+  }
+  echo '<a href="#" onclick="yms_show_notes(this, '.$id.'); return false;" title="' . $str . '"><img alt="' . $str . '" src="' . scriptPath . '/plugins/yms/icons/' . $img . '" /></a>';
+  
+  if ( !empty($notes) )
+  {
+    echo ' ';
+    if ( strlen($notes) > 15 )
+      echo htmlspecialchars(substr($notes, 0, 12)) . '...';
+    else
+      echo htmlspecialchars($notes);
+  }
+}
+
+function yms_show_actions($row)
+{
+  global $lang;
+  
+  // Show AES secret
+  ?>
+    <a href="<?php echo makeUrlNS('Special', "YMS/ShowAESKey/{$row['id']}"); ?>" title="<?php echo $lang->get('yms_btn_show_aes'); ?>" onclick="yms_showpage('ShowAESKey/<?php echo $row['id']; ?>'); return false;">
+      <img alt="<?php echo $lang->get('yms_btn_show_aes'); ?>" src="<?php echo scriptPath; ?>/plugins/yms/icons/key_go.png" />
+    </a>
+  <?php
+}