/* 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/. */

import { ctypes } from "resource://gre/modules/ctypes.sys.mjs";

const FLAGS_NOT_SET = 0;

const wintypes = {
  BOOL: ctypes.bool,
  BYTE: ctypes.uint8_t,
  DWORD: ctypes.uint32_t,
  PBYTE: ctypes.unsigned_char.ptr,
  PCHAR: ctypes.char.ptr,
  PDWORD: ctypes.uint32_t.ptr,
  PVOID: ctypes.voidptr_t,
  WORD: ctypes.uint16_t,
};

export function OSCrypto() {
  this._structs = {};
  this._functions = new Map();
  this._libs = new Map();
  this._structs.DATA_BLOB = new ctypes.StructType("DATA_BLOB", [
    { cbData: wintypes.DWORD },
    { pbData: wintypes.PVOID },
  ]);

  try {
    this._libs.set("crypt32", ctypes.open("Crypt32"));
    this._libs.set("kernel32", ctypes.open("Kernel32"));

    this._functions.set(
      "CryptProtectData",
      this._libs
        .get("crypt32")
        .declare(
          "CryptProtectData",
          ctypes.winapi_abi,
          wintypes.DWORD,
          this._structs.DATA_BLOB.ptr,
          wintypes.PVOID,
          wintypes.PVOID,
          wintypes.PVOID,
          wintypes.PVOID,
          wintypes.DWORD,
          this._structs.DATA_BLOB.ptr
        )
    );
    this._functions.set(
      "CryptUnprotectData",
      this._libs
        .get("crypt32")
        .declare(
          "CryptUnprotectData",
          ctypes.winapi_abi,
          wintypes.DWORD,
          this._structs.DATA_BLOB.ptr,
          wintypes.PVOID,
          wintypes.PVOID,
          wintypes.PVOID,
          wintypes.PVOID,
          wintypes.DWORD,
          this._structs.DATA_BLOB.ptr
        )
    );
    this._functions.set(
      "LocalFree",
      this._libs
        .get("kernel32")
        .declare("LocalFree", ctypes.winapi_abi, wintypes.DWORD, wintypes.PVOID)
    );
  } catch (ex) {
    console.error(ex);
    this.finalize();
    throw ex;
  }
}

OSCrypto.prototype = {
  /**
   * Convert an array containing only two bytes unsigned numbers to a string.
   *
   * @param {number[]} arr - the array that needs to be converted.
   * @returns {string} the string representation of the array.
   */
  arrayToString(arr) {
    let str = "";
    for (let i = 0; i < arr.length; i++) {
      str += String.fromCharCode(arr[i]);
    }
    return str;
  },

  /**
   * Convert a string to an array.
   *
   * @param {string} str - the string that needs to be converted.
   * @returns {number[]} the array representation of the string.
   */
  stringToArray(str) {
    let arr = [];
    for (let i = 0; i < str.length; i++) {
      arr.push(str.charCodeAt(i));
    }
    return arr;
  },

  /**
   * Calculate the hash value used by IE as the name of the registry value where login details are
   * stored.
   *
   * @param {string} data - the string value that needs to be hashed.
   * @returns {string} the hash value of the string.
   */
  getIELoginHash(data) {
    // return the two-digit hexadecimal code for a byte
    function toHexString(charCode) {
      return ("00" + charCode.toString(16)).slice(-2);
    }

    // the data needs to be encoded in null terminated UTF-16
    data += "\0";

    // dataArray is an array of bytes
    let dataArray = new Array(data.length * 2);
    for (let i = 0; i < data.length; i++) {
      let c = data.charCodeAt(i);
      let lo = c & 0xff;
      let hi = (c & 0xff00) >> 8;
      dataArray[i * 2] = lo;
      dataArray[i * 2 + 1] = hi;
    }

    // calculation of SHA1 hash value
    let cryptoHash = Cc["@mozilla.org/security/hash;1"].createInstance(
      Ci.nsICryptoHash
    );
    cryptoHash.init(cryptoHash.SHA1);
    cryptoHash.update(dataArray, dataArray.length);
    let hash = cryptoHash.finish(false);

    let tail = 0; // variable to calculate value for the last 2 bytes
    // convert to a character string in hexadecimal notation
    for (let c of hash) {
      tail += c.charCodeAt(0);
    }
    hash += String.fromCharCode(tail % 256);

    // convert the binary hash data to a hex string.
    let hashStr = Array.from(hash, (c, i) =>
      toHexString(hash.charCodeAt(i))
    ).join("");
    return hashStr.toUpperCase();
  },

  _getEntropyParam(entropy) {
    if (!entropy) {
      return null;
    }
    let entropyArray = this.stringToArray(entropy);
    entropyArray.push(0); // Null-terminate the data.
    let entropyData = wintypes.WORD.array(entropyArray.length)(entropyArray);
    let optionalEntropy = new this._structs.DATA_BLOB(
      entropyData.length * wintypes.WORD.size,
      entropyData
    );
    return optionalEntropy.address();
  },

  _convertWinArrayToJSArray(dataBlob) {
    // Convert DATA_BLOB to JS accessible array
    return ctypes.cast(
      dataBlob.pbData,
      wintypes.BYTE.array(dataBlob.cbData).ptr
    ).contents;
  },

  /**
   * Decrypt a string using the windows CryptUnprotectData API.
   *
   * @param {string} data - the encrypted string that needs to be decrypted.
   * @param {?string} entropy - the entropy value of the decryption (could be null). Its value must
   * be the same as the one used when the data was encrypted.
   * @param {?string} output - how to return the decrypted data default string
   * @returns {string|Uint8Array} the decryption of the string.
   */
  decryptData(data, entropy = null, output = "string") {
    let array = this.stringToArray(data);
    let encryptedData = wintypes.BYTE.array(array.length)(array);
    let inData = new this._structs.DATA_BLOB(
      encryptedData.length,
      encryptedData
    );
    let outData = new this._structs.DATA_BLOB();
    let entropyParam = this._getEntropyParam(entropy);

    try {
      let status = this._functions.get("CryptUnprotectData")(
        inData.address(),
        null,
        entropyParam,
        null,
        null,
        FLAGS_NOT_SET,
        outData.address()
      );
      if (status === 0) {
        throw new Error("decryptData failed: " + ctypes.winLastError);
      }

      let decrypted = this._convertWinArrayToJSArray(outData);

      // Return raw bytes to handle non-string results
      const bytes = new Uint8Array(decrypted);
      if (output === "bytes") {
        return bytes;
      }

      // Output that may include characters outside of the 0-255 (byte) range needs to use TextDecoder.
      return new TextDecoder().decode(bytes);
    } finally {
      if (outData.pbData) {
        this._functions.get("LocalFree")(outData.pbData);
      }
    }
  },

  /**
   * Encrypt a string using the windows CryptProtectData API.
   *
   * @param {string} data - the string that is going to be encrypted.
   * @param {?string} entropy - the entropy value of the encryption (could be null). Its value must
   * be the same as the one that is going to be used for the decryption.
   * @returns {string} the encrypted string.
   */
  encryptData(data, entropy = null) {
    // Input that may include characters outside of the 0-255 (byte) range needs to use TextEncoder.
    let decryptedByteData = [...new TextEncoder().encode(data)];
    let decryptedData = wintypes.BYTE.array(decryptedByteData.length)(
      decryptedByteData
    );

    let inData = new this._structs.DATA_BLOB(
      decryptedData.length,
      decryptedData
    );
    let outData = new this._structs.DATA_BLOB();
    let entropyParam = this._getEntropyParam(entropy);

    try {
      let status = this._functions.get("CryptProtectData")(
        inData.address(),
        null,
        entropyParam,
        null,
        null,
        FLAGS_NOT_SET,
        outData.address()
      );
      if (status === 0) {
        throw new Error("encryptData failed: " + ctypes.winLastError);
      }

      let encrypted = this._convertWinArrayToJSArray(outData);
      return this.arrayToString(encrypted);
    } finally {
      if (outData.pbData) {
        this._functions.get("LocalFree")(outData.pbData);
      }
    }
  },

  /**
   * Must be invoked once after last use of any of the provided helpers.
   */
  finalize() {
    this._structs = {};
    this._functions.clear();
    for (let lib of this._libs.values()) {
      try {
        lib.close();
      } catch (ex) {
        console.error(ex);
      }
    }
    this._libs.clear();
  },
};
