// Copyright 2014 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * @fileoverview Does common handling for requests coming from web pages and
 * routes them to the provided handler.
 */

/**
 * FIDO U2F Javascript API Version
 * @const
 * @type {number}
 */
var JS_API_VERSION = 1.1;

/**
 * Gets the scheme + origin from a web url.
 * @param {string} url Input url
 * @return {?string} Scheme and origin part if url parses
 */
function getOriginFromUrl(url) {
  var re = new RegExp('^(https?://)[^/]*/?');
  var originarray = re.exec(url);
  if (originarray == null)
    return originarray;
  var origin = originarray[0];
  while (origin.charAt(origin.length - 1) == '/') {
    origin = origin.substring(0, origin.length - 1);
  }
  if (origin == 'http:' || origin == 'https:')
    return null;
  return origin;
}

/**
 * Returns whether the registered key appears to be valid.
 * @param {Object} registeredKey The registered key object.
 * @param {boolean} appIdRequired Whether the appId property is required on
 *     each challenge.
 * @return {boolean} Whether the object appears valid.
 */
function isValidRegisteredKey(registeredKey, appIdRequired) {
  if (appIdRequired && !registeredKey.hasOwnProperty('appId')) {
    return false;
  }
  if (!registeredKey.hasOwnProperty('keyHandle'))
    return false;
  if (registeredKey['version']) {
    if (registeredKey['version'] != 'U2F_V1' &&
        registeredKey['version'] != 'U2F_V2') {
      return false;
    }
  }
  return true;
}

/**
 * Returns whether the array of registered keys appears to be valid.
 * @param {Array<Object>} registeredKeys The array of registered keys.
 * @param {boolean} appIdRequired Whether the appId property is required on
 *     each challenge.
 * @return {boolean} Whether the array appears valid.
 */
function isValidRegisteredKeyArray(registeredKeys, appIdRequired) {
  return registeredKeys.every(function(key) {
    return isValidRegisteredKey(key, appIdRequired);
  });
}

/**
 * Gets the sign challenges from the request. The sign challenges may be the
 * U2F 1.0 variant, signRequests, or the U2F 1.1 version, registeredKeys.
 * @param {Object} request The request.
 * @return {!Array<SignChallenge>|undefined} The sign challenges, if found.
 */
function getSignChallenges(request) {
  if (!request) {
    return undefined;
  }
  var signChallenges;
  if (request.hasOwnProperty('signRequests')) {
    signChallenges = request['signRequests'];
  } else if (request.hasOwnProperty('registeredKeys')) {
    signChallenges = request['registeredKeys'];
  }
  return signChallenges;
}

/**
 * Returns whether the array of SignChallenges appears to be valid.
 * @param {Array<SignChallenge>} signChallenges The array of sign challenges.
 * @param {boolean} challengeValueRequired Whether each challenge object
 *     requires a challenge value.
 * @param {boolean} appIdRequired Whether the appId property is required on
 *     each challenge.
 * @return {boolean} Whether the array appears valid.
 */
function isValidSignChallengeArray(
    signChallenges, challengeValueRequired, appIdRequired) {
  for (var i = 0; i < signChallenges.length; i++) {
    var incomingChallenge = signChallenges[i];
    if (challengeValueRequired &&
        !incomingChallenge.hasOwnProperty('challenge'))
      return false;
    if (!isValidRegisteredKey(incomingChallenge, appIdRequired)) {
      return false;
    }
  }
  return true;
}

/**
 * @param {Object} request Request object
 * @param {MessageSender} sender Sender frame
 * @param {Function} sendResponse Response callback
 * @return {?Closeable} Optional handler object that should be closed when port
 *     closes
 */
function handleWebPageRequest(request, sender, sendResponse) {
  switch (request.type) {
    case MessageTypes.U2F_REGISTER_REQUEST:
      return handleU2fEnrollRequest(sender, request, sendResponse);

    case MessageTypes.U2F_SIGN_REQUEST:
      return handleU2fSignRequest(sender, request, sendResponse);

    case MessageTypes.U2F_GET_API_VERSION_REQUEST:
      sendResponse(makeU2fGetApiVersionResponse(
          request, JS_API_VERSION, MessageTypes.U2F_GET_API_VERSION_RESPONSE));
      return null;

    default:
      sendResponse(makeU2fErrorResponse(
          request, ErrorCodes.BAD_REQUEST, undefined,
          MessageTypes.U2F_REGISTER_RESPONSE));
      return null;
  }
}

/**
 * Makes a response to a request.
 * @param {Object} request The request to make a response to.
 * @param {string} responseSuffix How to name the response's type.
 * @param {string=} opt_defaultType The default response type, if none is
 *     present in the request.
 * @return {Object} The response object.
 */
function makeResponseForRequest(request, responseSuffix, opt_defaultType) {
  var type;
  if (request && request.type) {
    type = request.type.replace(/_request$/, responseSuffix);
  } else {
    type = opt_defaultType;
  }
  var reply = {'type': type};
  if (request && request.requestId) {
    reply.requestId = request.requestId;
  }
  return reply;
}

/**
 * Makes a response to a U2F request with an error code.
 * @param {Object} request The request to make a response to.
 * @param {ErrorCodes} code The error code to return.
 * @param {string=} opt_detail An error detail string.
 * @param {string=} opt_defaultType The default response type, if none is
 *     present in the request.
 * @return {Object} The U2F error.
 */
function makeU2fErrorResponse(request, code, opt_detail, opt_defaultType) {
  var reply = makeResponseForRequest(request, '_response', opt_defaultType);
  var error = {'errorCode': code};
  if (opt_detail) {
    error['errorMessage'] = opt_detail;
  }
  reply['responseData'] = error;
  return reply;
}

/**
 * Makes a success response to a web request with a responseData object.
 * @param {Object} request The request to make a response to.
 * @param {Object} responseData The response data.
 * @return {Object} The web error.
 */
function makeU2fSuccessResponse(request, responseData) {
  var reply = makeResponseForRequest(request, '_response');
  reply['responseData'] = responseData;
  return reply;
}

/**
 * Maps a helper's error code from the DeviceStatusCodes namespace to a
 * U2fError.
 * @param {number} code Error code from DeviceStatusCodes namespace.
 * @return {U2fError} An error.
 */
function mapDeviceStatusCodeToU2fError(code) {
  switch (code) {
    case DeviceStatusCodes.WRONG_DATA_STATUS:
      return {errorCode: ErrorCodes.DEVICE_INELIGIBLE};

    case DeviceStatusCodes.TIMEOUT_STATUS:
    case DeviceStatusCodes.WAIT_TOUCH_STATUS:
      return {errorCode: ErrorCodes.TIMEOUT};

    default:
      var reportedError = {
        errorCode: ErrorCodes.OTHER_ERROR,
        errorMessage: 'device status code: ' + code.toString(16)
      };
      return reportedError;
  }
}

/**
 * Sends a response, using the given sentinel to ensure at most one response is
 * sent. Also closes the closeable, if it's given.
 * @param {boolean} sentResponse Whether a response has already been sent.
 * @param {?Closeable} closeable A thing to close.
 * @param {*} response The response to send.
 * @param {Function} sendResponse A function to send the response.
 */
function sendResponseOnce(sentResponse, closeable, response, sendResponse) {
  if (closeable) {
    closeable.close();
  }
  if (!sentResponse) {
    sentResponse = true;
    try {
      // If the page has gone away or the connection has otherwise gone,
      // sendResponse fails.
      sendResponse(response);
    } catch (exception) {
      console.warn('sendResponse failed: ' + exception);
    }
  } else {
    console.warn(UTIL_fmt('Tried to reply more than once!'));
  }
}

/**
 * @param {!string} string Input string
 * @return {!Array<number>} SHA256 hash value of string.
 */
function sha256HashOfString(string) {
  var s = new SHA256();
  s.update(UTIL_StringToBytes(string));
  return s.digest();
}

var UNUSED_CID_PUBKEY_VALUE = 'unused';

/**
 * Normalizes the TLS channel ID value:
 * 1. Converts semantically empty values (undefined, null, 0) to the empty
 *     string.
 * 2. Converts valid JSON strings to a JS object.
 * 3. Otherwise, returns the input value unmodified.
 * @param {Object|string|undefined} opt_tlsChannelId TLS Channel id
 * @return {Object|string} The normalized TLS channel ID value.
 */
function tlsChannelIdValue(opt_tlsChannelId) {
  if (!opt_tlsChannelId) {
    // Case 1: Always set some value for TLS channel ID, even if it's the empty
    // string: this browser definitely supports them.
    return UNUSED_CID_PUBKEY_VALUE;
  }
  if (typeof opt_tlsChannelId === 'string') {
    try {
      var obj = JSON.parse(opt_tlsChannelId);
      if (!obj) {
        // Case 1: The string value 'null' parses as the Javascript object null,
        // so return an empty string: the browser definitely supports TLS
        // channel id.
        return UNUSED_CID_PUBKEY_VALUE;
      }
      // Case 2: return the value as a JS object.
      return /** @type {Object} */ (obj);
    } catch (e) {
      console.warn('Unparseable TLS channel ID value ' + opt_tlsChannelId);
      // Case 3: return the value unmodified.
    }
  }
  return opt_tlsChannelId;
}

/**
 * Creates a browser data object with the given values.
 * @param {!string} type A string representing the "type" of this browser data
 *     object.
 * @param {!string} serverChallenge The server's challenge, as a base64-
 *     encoded string.
 * @param {!string} origin The server's origin, as seen by the browser.
 * @param {Object|string|undefined} opt_tlsChannelId TLS Channel Id
 * @return {string} A string representation of the browser data object.
 */
function makeBrowserData(type, serverChallenge, origin, opt_tlsChannelId) {
  var browserData = {
    'typ': type,
    'challenge': serverChallenge,
    'origin': origin
  };
  if (BROWSER_SUPPORTS_TLS_CHANNEL_ID) {
    browserData['cid_pubkey'] = tlsChannelIdValue(opt_tlsChannelId);
  }
  return JSON.stringify(browserData);
}

/**
 * Creates a browser data object for an enroll request with the given values.
 * @param {!string} serverChallenge The server's challenge, as a base64-
 *     encoded string.
 * @param {!string} origin The server's origin, as seen by the browser.
 * @param {Object|string|undefined} opt_tlsChannelId TLS Channel Id
 * @return {string} A string representation of the browser data object.
 */
function makeEnrollBrowserData(serverChallenge, origin, opt_tlsChannelId) {
  return makeBrowserData(
      'navigator.id.finishEnrollment', serverChallenge, origin,
      opt_tlsChannelId);
}

/**
 * Creates a browser data object for a sign request with the given values.
 * @param {!string} serverChallenge The server's challenge, as a base64-
 *     encoded string.
 * @param {!string} origin The server's origin, as seen by the browser.
 * @param {Object|string|undefined} opt_tlsChannelId TLS Channel Id
 * @return {string} A string representation of the browser data object.
 */
function makeSignBrowserData(serverChallenge, origin, opt_tlsChannelId) {
  return makeBrowserData(
      'navigator.id.getAssertion', serverChallenge, origin, opt_tlsChannelId);
}

/**
 * Makes a response to a U2F request with an error code.
 * @param {Object} request The request to make a response to.
 * @param {number=} version The JS API version to return.
 * @param {string=} opt_defaultType The default response type, if none is
 *     present in the request.
 * @return {Object} The GetJsApiVersionResponse.
 */
function makeU2fGetApiVersionResponse(request, version, opt_defaultType) {
  var reply = makeResponseForRequest(request, '_response', opt_defaultType);
  var data = {'js_api_version': version};
  reply['responseData'] = data;
  return reply;
}

/**
 * Encodes the sign data as an array of sign helper challenges.
 * @param {Array<SignChallenge>} signChallenges The sign challenges to encode.
 * @param {string|undefined} opt_defaultChallenge A default sign challenge
 *     value, if a request does not provide one.
 * @param {string=} opt_defaultAppId The app id to use for each challenge, if
 *     the challenge contains none.
 * @param {function(string, string): string=} opt_challengeHashFunction
 *     A function that produces, from a key handle and a raw challenge, a hash
 *     of the raw challenge. If none is provided, a default hash function is
 *     used.
 * @return {!Array<SignHelperChallenge>} The sign challenges, encoded.
 */
function encodeSignChallenges(
    signChallenges, opt_defaultChallenge, opt_defaultAppId,
    opt_challengeHashFunction) {
  function encodedSha256(keyHandle, challenge) {
    return B64_encode(sha256HashOfString(challenge));
  }
  var challengeHashFn = opt_challengeHashFunction || encodedSha256;
  var encodedSignChallenges = [];
  if (signChallenges) {
    for (var i = 0; i < signChallenges.length; i++) {
      var challenge = signChallenges[i];
      var keyHandle = challenge['keyHandle'];
      var challengeValue;
      if (challenge.hasOwnProperty('challenge')) {
        challengeValue = challenge['challenge'];
      } else {
        challengeValue = opt_defaultChallenge;
      }
      var challengeHash = challengeHashFn(keyHandle, challengeValue);
      var appId;
      if (challenge.hasOwnProperty('appId')) {
        appId = challenge['appId'];
      } else {
        appId = opt_defaultAppId;
      }
      var encodedChallenge = {
        'challengeHash': challengeHash,
        'appIdHash': B64_encode(sha256HashOfString(appId)),
        'keyHandle': keyHandle,
        'version': (challenge['version'] || 'U2F_V1')
      };
      encodedSignChallenges.push(encodedChallenge);
    }
  }
  return encodedSignChallenges;
}

/**
 * Makes a sign helper request from an array of challenges.
 * @param {Array<SignHelperChallenge>} challenges The sign challenges.
 * @param {number=} opt_timeoutSeconds Timeout value.
 * @param {string=} opt_logMsgUrl URL to log to.
 * @return {SignHelperRequest} The sign helper request.
 */
function makeSignHelperRequest(challenges, opt_timeoutSeconds, opt_logMsgUrl) {
  var request = {
    'type': 'sign_helper_request',
    'signData': challenges,
    'timeout': opt_timeoutSeconds || 0,
    'timeoutSeconds': opt_timeoutSeconds || 0
  };
  if (opt_logMsgUrl !== undefined) {
    request.logMsgUrl = opt_logMsgUrl;
  }
  return request;
}
