UNPKG

yubico

Version:

Node library for validating Yubico One Time Passwords (OTPs) based on the validation protocol version 2.0.

534 lines (434 loc) 16.5 kB
var sys = require('sys'); var http = require('http'); var querystring = require('querystring'); var crypto = require('crypto'); var base64 = require('./extern/base64'); var sprintf = require('./extern/sprintf').sprintf; var OTP = require('./otp').OTP; var constants = require('./constants'); var exceptions = require('./exceptions'); var utils = require('./utils'); /** * * @constructor * @param {Number} client_id Client ID * @param {String} key API key * @param {Boolean} use_https True to use HTTPS (defaults to true) * * Note: You can get client_id and API key at https://upgrade.yubico.com/getapikey/ */ function Yubico(client_id, key, use_https) { this._client_id = client_id; this._key = (key) ? base64.decode(key) : null; this._use_https = use_https || true; if (this._use_https && process.version.indexOf('v0.3') !== -1) { sys.error('https in node v0.3.x is currently broken, so you can\'t' + ' use https when using node v0.3.x'); this._use_https = false; } } /** * Verify the OTP token. * * @param {Number} timeout Connection timeout in seconds (defaults to 15 * seconds) * @param {Function} callback Callback which is called with a possible error * as the first argument and true as the second * one if the provided OTP is valid. */ Yubico.prototype.verify = function(otp, timeout, callback) { var timeout_, callback_, nonce; if (typeof timeout === 'function') { timeout_ = constants.TIMEOUT * 1000; callback_ = timeout; } else { timeout_ = timeout * 1000; callback_ = callback; } this._verify_token(otp, timeout_, false, function(err, success) { if (err) { callback_(err, false); return; } callback_(null, true); }); }; /** * Verify multiple OTP tokens. * * @param {Array} otp_list Array of OTP tokens. * @param {Number} max_time_window How many seconds can pass between the first * and the last OTP generation. * @param {Number} timeout Connection timeout in seconds (defaults to 15 * seconds) * @param {Function} callback Callback which is called with a possible error * as the first argument and true as the second * one if all the provided OTPs are valid. */ Yubico.prototype.verify_multi = function(otp_list, max_time_window, timeout, callback) { var self = this; var max_time_window_, timeout_, callback_; var i, otp_list_length, otp, otp_object, device_id, otps = [], device_ids = []; var time_first_otp, time_last_otp; var callback_called = false, result_count = 0; if (typeof max_time_window === 'function') { max_time_window_ = (constants.MAX_TIME_WINDOW / 0.125); timeout_ = (constants.TIMEOUT * 1000); callback_ = max_time_window; } else { max_time_window_ = (max_time_window / 0.125) || (constants.MAX_TIME_WINDOW / 0.125); timeout_ = (timeout * 1000) || (constants.TIMEOUT * 1000); callback_ = callback; } if (otp_list.length <= 1) { callback_(new Error('otp_list array must contain at least two tokens.'), false); return; } var call_callback = function(err, success) { if (callback_called) { return; } callback_called = true; callback_(err, success); }; var parse_timestamp_from_response = function(response) { var parameters_array, parameters_object; parameters_array = self._parse_parameters_from_response(response); parameters_object = self._query_string_to_object(parameters_array[1]); return parameters_object.timestamp; }; var check_time_window = function() { var delta; delta = (time_last_otp - time_first_otp); if (delta > max_time_window_) { return true; } return false; }; var handle_got_result = function(err, result, response) { var time_window_reached, error; if (err) { call_callback(err, false); return; } if (result_count === 0) { time_first_otp = parse_timestamp_from_response(response); } if (result_count === otp_list_length - 1) { time_last_otp = parse_timestamp_from_response(response); } result_count++; if (result_count === otp_list_length) { time_window_reached = check_time_window(); if (time_window_reached) { error = new exceptions.TimeWindowReachedError(max_time_window_); call_callback(error, false); return; } call_callback(null, true); } else { self._verify_token(otps.shift(), timeout_, true, handle_got_result); } }; otp_list_length = otp_list.length; for (i = 0; i < otp_list_length; i++) { otp = otp_list[i]; otp_object = new OTP(otp); device_id = otp_object.get_device_id(); if (i > 0 && device_ids.indexOf(device_id) === -1) { callback_(new Error('OTPs contain different device IDs'), false); return; } device_ids.push(device_id); otps.push(otp); } this._verify_token(otps.shift(), timeout_, true, handle_got_result); }; /** * Verify the provided token. * * @param {String} otp OTP token. * @param {Number} timeout Number of seconds to wait for sync responses; if * absent, let the server decides. * @param {Boolean} return_response true to call callback with the response * string as the last argument. * @param {Function} callback Callback which is called with a possible error * as the first argument, true as the second one * if the provided OTP is valid and the response * string as the third one if return_response equals * true. */ Yubico.prototype._verify_token = function(otp, timeout, return_response, callback) { var self = this; var otp_, nonce, query_string; var i, client, request, host, port, path, error; var timeout_ids = [], client_objects = [], request_objects = []; var got_response = false, callback_called = false; var client_timed_out_count = 0; otp_ = new OTP(otp).get_otp(); nonce = base64.encode(utils.randstr(40)); nonce = nonce.substr(0, 30); query_string = this._generate_query_string(otp_, nonce, true, 75, (timeout / 1000)); var call_callback = function(err, success, response) { if (callback_called) { return; } if (!return_response) { response = undefined; } callback_called = true; callback(err, success, response); }; var handle_got_response = function() { var i, client_object, request_object, timeout_id; got_response = true; for (i = 0; i < client_objects.length; i++) { client_object = client_objects[i]; client_object.removeAllListeners('error'); if (client_object.writable) { client_object.destroy(); } } for (i = 0; i < request_objects.length; i++) { request_object = request_objects[i]; request_object.removeAllListeners('response'); } for (i = 0; i < timeout_ids.length; i++) { timeout_id = timeout_ids[i]; clearTimeout(timeout_id); } }; var clear_connect_timeout = function(timeout_id) { clearTimeout(timeout_id); }; var handle_response = function(response, client, timeout_id) { var data_buffer = []; if (got_response || callback_called) { return; } if (this._use_https && !client.verifyPeer()) { // Invalid SSL certificate error = exceptions.InvalidSSLCertificateError(client.getPeerCertificate()); call_callback(error, false, null); return; } clear_connect_timeout(timeout_id); response.on('data', function(chunk) { data_buffer.push(chunk); }); response.on('end', function() { var response, split, parsed_response, signature, parameters; var status, response_otp, generated_signature, error; response = data_buffer.join(''); status = response.match('status=([a-zA-Z0-9_]+)'); response_otp = response.match('otp=([a-zA-Z0-9]+)'); if (!status) { handle_got_response(); call_callback(new Error('Missing status attribute'), false, response); return; } status = status[1].trim().toLowerCase(); response_otp = response_otp[1].trim(); if (otp !== response_otp) { handle_got_response(); call_callback(new Error('OTP in the response does not match the' + ' provided OTP'), false, response); return; } if (self._key) { // Verify the response signature parsed_response = self._parse_parameters_from_response(response); signature = parsed_response[0]; parameters = parsed_response[1]; generated_signature = self._generate_message_signature(parameters, self._key); if (signature !== generated_signature) { handle_got_response(); error = new exceptions.SignatureVerificationError(generated_signature, signature); call_callback(error, false, response); return; } } if (status.toLowerCase() === 'ok') { handle_got_response(); call_callback(null, true, response); return; } else if (status === 'no_such_client') { handle_got_response(); error = new exceptions.InvaliClientError(self._client_id); call_callback(error, false, response); return; } else if (status === 'replayed_otp' || status === 'bad_otp' || status === 'bad_signature') { handle_got_response(); error = new exceptions.StatusCodeError(status); call_callback(error, false, response); return; } else if (status === 'replayed_request') { return; } handle_got_response(); error = new exceptions.StatusCodeError(status); call_callback(error, false, response); }); }; var handle_error = function(err) { client.removeAllListeners('response'); call_callback(err, false); }; var handle_timeout = function(client) { if (got_response || callback_called) { return; } if (client.writable) { client.destroy(); } client_timed_out_count++; client.removeAllListeners('response'); if (client_timed_out_count === client_objects.length) { call_callback(new exceptions.ConnectionTimeoutError(timeout)); } }; port = (this._use_https) ? 443 : 80; for (i = 0; i < constants.API_HOSTS.length ; i++) { host = constants.API_HOSTS[i]; path = sprintf('%s?%s', constants.API_PATH, query_string); client = http.createClient(port, host, this._use_https); request = client.request('GET', path, { 'host': host, 'User-Agent': sprintf('NodeJS Yubico library v%s.%s.%s', constants.VERSION[0], constants.VERSION[1], constants.VERSION[2]) }); request.end(); client.on('error', handle_error); (function(client) { var timeout_id; timeout_id = setTimeout(function() { handle_timeout(client); }, timeout); request.on('response', function(response) { handle_response(response, client, timeout_id); }); timeout_ids.push(timeout_id); })(client); client_objects.push(client); request_objects.push(request); } }; /** * Generate a query string which is sent to the validation servers. * * @param {String} otp Yubikey token. * @param {String} nonce 16 to 40 characters long string with random data. * @param {String} sl A value 0 to 100 indicating percentage of syncing required * by client, or strings "fast" or "secure" to use * server-configured values; if * absent, let the server decides. * @param {Number} timeout Number of seconds to wait for sync responses; if * absent, let the server decide. * @return {String} Generated query string. */ Yubico.prototype._generate_query_string = function(otp, nonce, timestamp, sl, timeout) { var data, query_string, hmac_signature; data = { 'id': this._client_id, 'otp': otp, 'nonce': nonce }; if (timestamp) { data.timestamp = 1; } if (sl) { if (sl < 0 || sl > 100 && [ 'fast', 'secure' ].indexOf(sl) === -1) { throw new Error('sl parameter value must be between 0 and 100 or string "fast" or "secure"'); } data.sl = sl; } if (timeout) { data.timeout = timeout; } query_string = querystring.encode(data); if (this._key) { hmac_signature = this._generate_message_signature(query_string, this._key); hmac_signature = hmac_signature.replace(/\+/g, '%2B'); query_string = sprintf('%s&h=%s', query_string, hmac_signature); } return query_string; }; /** * Parse parameters from the response. * * @param {String} response Response string * @return {Array} Array where the first member is parsed signature and the * second one if the rest of the parsed response returned as * a query string. */ Yubico.prototype._parse_parameters_from_response = function(response) { var i, split = [], pairs, pair, signature, query_string; pairs = response.split('\n'); for (i = 0; i < pairs.length; i++) { pair = pairs[i].trim(); if (pair === '') { continue; } split.push(pair); } signature = split[0].replace('h=', ''); query_string = split.slice(1).join('&'); return [ signature, query_string ]; }; /** * Convert a query string to an object. * * @param {String} query_string Query string (e.g. foo=bar&bar=baz) * @return {Object} Object where the key is a parameter name and the value is * is the parameter value. */ Yubico.prototype._query_string_to_object = function(query_string) { var i, pairs, pair, parameters = {}; pairs = query_string.split('&'); for (i = 0; i < pairs.length; i++) { pair = pairs[i].trim(); if (pair === '') { continue; } pair = pair.split('='); parameters[pair[0]] = pair[1]; } return parameters; }; /** * Generate HMAC-SHA1 signature for the given query string using the * provided key. * * @param {String} query_string Query string * @param {String} key Cryptographic key. * @return {String} base64 encoded signature. */ Yubico.prototype._generate_message_signature = function(query_string, key) { var i, pairs, pair, pairs_sorted, pairs_string, hmac, signature; pairs = query_string.split('&'); pairs_sorted = []; for (i = 0; i < pairs.length; i++) { pair = pairs[i]; pair = pair.split('='); pairs_sorted.push(pair); } pairs_sorted.sort(); pairs = []; for (i = 0; i < pairs_sorted.length; i++) { pair = pairs_sorted[i]; pairs[i] = pair.join('='); } pairs_string = pairs.join('&'); hmac = crypto.createHmac('sha1', key); hmac.update(pairs_string); signature = hmac.digest('base64'); return signature; }; exports.Yubico = Yubico;