/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
 * You can obtain one at http://mozilla.org/MPL/2.0/. */

/* global Components */

var loop = loop || {};
var inChrome = typeof Components != "undefined" && "utils" in Components;

(function(rootObject) {
  "use strict";

  var sharedUtils;
  if (inChrome) {
    this.EXPORTED_SYMBOLS = ["LoopCrypto"];
    var Cu = Components.utils;
    Cu.importGlobalProperties(["crypto"]);
    rootObject = {
      crypto: crypto
    };
    sharedUtils = Cu.import("chrome://loop/content/shared/js/utils.js", {}).utils;
  } else {
    sharedUtils = this.shared.utils;
  }

  var ALGORITHM = "AES-GCM";
  var KEY_LENGTH = 128;
  // We use JSON web key formats for the generated keys.
  // https://tools.ietf.org/html/draft-ietf-jose-json-web-key-41
  var KEY_FORMAT = "jwk";
  // This is the JSON web key type from the generateKey algorithm.
  var KEY_TYPE = "oct";
  var ENCRYPT_TAG_LENGTH = 128;
  var INITIALIZATION_VECTOR_LENGTH = 12;

  /**
   * Sets a new root object.  This is useful for testing crypto not supported as
   * it allows us to fake crypto not being present.
   * In beforeEach(), loop.crypto.setRootObject is used to
   * substitute a fake window, and in afterEach(), the real window object is
   * replaced.
   *
   * @param {Object}
   */
  function setRootObject(obj) {
    rootObject = obj;
  }

  /**
   * Determines if Web Crypto is supported by this browser.
   *
   * @return {Boolean} True if Web Crypto is supported
   */
  function isSupported() {
    return "crypto" in rootObject;
  }

  /**
   * Generates a random key using the Web Crypto libraries.
   *
   * @return {Promise} A promise which is rejected on failure, or resolved
   *                   with a string that is in the JSON web key format.
   */
  function generateKey() {
    if (!isSupported()) {
      throw new Error("Web Crypto is not supported");
    }

    return new Promise(function(resolve, reject) {
      // First get a crypto key.
      rootObject.crypto.subtle.generateKey({ name: ALGORITHM, length: KEY_LENGTH },
        // `true` means that the key can be extracted from the CryptoKey object.
        true,
        // Usages for the key.
        ["encrypt", "decrypt"]
      ).then(function(cryptoKey) {
        // Now extract the key in the JSON web key format.
        return rootObject.crypto.subtle.exportKey(KEY_FORMAT, cryptoKey);
      }).then(function(exportedKey) {
        // Lastly resolve the promise with the new key.
        resolve(exportedKey.k);
      }).catch(function(error) {
        reject(error);
      });
    });
  }

  /**
   * Encrypts an object using the specified key.
   *
   * @param {String} key      The key to use for encryption. This should have
   *                          been generated by generateKey.
   * @param {String} data     The string to be encrypted.
   *
   * @return {Promise} A promise which is rejected on failure, or resolved
   *                   with a string that is the encrypted context.
   */
  function encryptBytes(key, data) {
    if (!isSupported()) {
      throw new Error("Web Crypto is not supported");
    }

    var iv = new Uint8Array(INITIALIZATION_VECTOR_LENGTH);

    return new Promise(function(resolve, reject) {
      // First import the key to a format we can use.
      rootObject.crypto.subtle.importKey(KEY_FORMAT,
        { k: key, kty: KEY_TYPE },
        ALGORITHM,
        // If the key is extractable.
        true,
        // What we're using it for.
        ["encrypt"]
      ).then(function(cryptoKey) {
        // Now we've got the cryptoKey, we can do the actual encryption.

        // First get the data into the format we need.
        var dataBuffer = sharedUtils.strToUint8Array(data);

        // It is critically important to change the IV any time the
        // encrypted information is updated.
        rootObject.crypto.getRandomValues(iv);

        return rootObject.crypto.subtle.encrypt({
            name: ALGORITHM,
            iv: iv,
            tagLength: ENCRYPT_TAG_LENGTH
          }, cryptoKey,
          dataBuffer);
      }).then(function(cipherText) {
        // Join the initialization vector and context for returning.
        var joinedData = _mergeIVandCipherText(iv, new DataView(cipherText));

        // Now convert to a string and base-64 encode.
        var encryptedData = sharedUtils.btoa(joinedData);

        resolve(encryptedData);
      }).catch(function(error) {
        reject(error);
      });
    });
  }

  /**
   * Decrypts an object using the specified key.
   *
   * @param {String} key           The key to use for encryption. This should have
   *                               been generated by generateKey.
   * @param {String} encryptedData The encrypted context.
   * @return {Promise} A promise which is rejected on failure, or resolved
   *                   with a string that is the decrypted context.
   */
  function decryptBytes(key, encryptedData) {
    if (!isSupported()) {
      throw new Error("Web Crypto is not supported");
    }

    return new Promise(function(resolve, reject) {
      // First import the key to a format we can use.
      rootObject.crypto.subtle.importKey(KEY_FORMAT,
        { k: key, kty: KEY_TYPE },
        ALGORITHM,
        // If the key is extractable.
        true,
        // What we're using it for.
        ["decrypt"]
      ).then(function(cryptoKey) {
        // Now we've got the key, start the decryption.
        var splitData = _splitIVandCipherText(encryptedData);

        return rootObject.crypto.subtle.decrypt({
          name: ALGORITHM,
          iv: splitData.iv,
          tagLength: ENCRYPT_TAG_LENGTH
        }, cryptoKey, splitData.cipherText);
      }).then(function(plainText) {
        // Now we just turn it back into a string and then an object.
        resolve(sharedUtils.Uint8ArrayToStr(new Uint8Array(plainText)));
      }).catch(function(error) {
        reject(error);
      });
    });
  }

  /**
   * Appends the cipher text to the end of the initialization vector and
   * returns the result.
   *
   * @param {Uint8Array} ivArray The array of initialization vector values.
   * @param {DataView} cipherTextDataView The cipherText in data view format.
   * @return {Uint8Array} An array of the IV and cipherText.
   */
  function _mergeIVandCipherText(ivArray, cipherTextDataView) {
    // First we translate the data view to an array so we can get
    // the length.
    var cipherText = new Uint8Array(cipherTextDataView.buffer);
    var cipherTextLength = cipherText.length;

    var joinedContext = new Uint8Array(INITIALIZATION_VECTOR_LENGTH + cipherTextLength);

    var i;
    for (i = 0; i < INITIALIZATION_VECTOR_LENGTH; i++) {
      joinedContext[i] = ivArray[i];
    }

    for (i = 0; i < cipherTextLength; i++) {
      joinedContext[i + INITIALIZATION_VECTOR_LENGTH] = cipherText[i];
    }

    return joinedContext;
  }

  /**
   * Takes the IV from the start of the passed in array and separates
   * out the cipher text.
   *
   * @param {String} encryptedData Encrypted data in base64 format.
   * @return {Object} An object consisting of two items: iv and cipherText,
   *                  both are Uint8Arrays.
   */
  function _splitIVandCipherText(encryptedData) {
    // Convert into byte arrays.
    var encryptedDataArray = sharedUtils.atob(encryptedData);

    // Now split out the initialization vector and the cipherText.
    var iv = encryptedDataArray.slice(0, INITIALIZATION_VECTOR_LENGTH);
    var cipherText = encryptedDataArray.slice(INITIALIZATION_VECTOR_LENGTH,
                                              encryptedDataArray.length);

    return {
      iv: iv,
      cipherText: cipherText
    };
  }

  this[inChrome ? "LoopCrypto" : "crypto"] = {
    decryptBytes: decryptBytes,
    encryptBytes: encryptBytes,
    generateKey: generateKey,
    isSupported: isSupported,
    setRootObject: setRootObject
  };
}).call(inChrome ? this : loop, this);
