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

/**
 * This file implements the authentication mechanisms
 * - AUTH LOGIN
 * - AUTH PLAIN
 * - AUTH CRAM-MD5
 * for all the server implementations, i.e. in a generic way.
 * In fact, you could use this to implement a real server in JS :-) .
 *
 * @author Ben Bucksch <ben.bucksch beonex.com>
 */

/**
 * Implements AUTH PLAIN
 *
 * @see RFC 4616
 */
export var AuthPLAIN = {
  /**
   * Takes full PLAIN auth line, and decodes it.
   *
   * @param {string} line
   * @returns {object} object
   * @returns {string} object.username
   * @returns {string} object.password
   * @throws {Error} error to return to client
   */
  decodeLine(line) {
    dump("AUTH PLAIN line -" + line + "-\n");
    line = atob(line); // base64 decode
    const aap = line.split("\u0000"); // 0-charater is delimiter
    if (aap.length != 3) {
      throw new Error("Expected three parts");
    }
    /* aap is: authorize-id, authenticate-id, password.
       Generally, authorize-id = authenticate-id = username.
       authorize-id may thus be empty and then defaults to authenticate-id. */
    var result = {};
    var authzid = aap[0];
    result.username = aap[1];
    result.password = aap[2];
    dump(
      "authorize-id: -" +
        authzid +
        "-, username: -" +
        result.username +
        "-, password: -" +
        result.password +
        "-\n"
    );
    if (authzid && authzid != result.username) {
      throw new Error(
        "Expecting a authorize-id that's either the same as authenticate-id or empty"
      );
    }
    return result;
  },

  /**
   * Create an AUTH PLAIN line, to allow a client to authenticate to a server.
   * Useful for tests.
   */
  encodeLine(username, password) {
    username = username.substring(0, 255);
    password = password.substring(0, 255);
    return btoa("\u0000" + username + "\u0000" + password); // base64 encode
  },
};

export var AuthLOGIN = {
  /**
   * Takes full LOGIN auth line, and decodes it.
   * It may contain either username or password,
   * depending on state/step (first username, then pw).
   *
   * @param {string} line
   * @returns {string} base64 decoded line
   * @throws {Error} on decoding errors
   */
  decodeLine(line) {
    dump("AUTH LOGIN -" + atob(line) + "-\n");
    return atob(line); // base64 decode
  },
};

/**
 * Implements AUTH CRAM-MD5
 *
 * @see RFC 2195, RFC 2104
 */
export var AuthCRAM = {
  /**
   * First part of CRAM exchange is that the server sends
   * a challenge to the client. The client response depends on
   * the challenge. (This prevents replay attacks, I think.)
   * This function generates the challenge.
   *
   * You need to store it, you'll need it to check the client response.
   *
   * @param {string} domain - Your hostname or domain,
   *    e.g. "example.com", "mx.example.com" or just "localhost".
   * @returns {string} The challenge.
   *   It's already base64-encoded. Send it as-is to the client.
   */
  createChallenge(domain) {
    var timestamp = new Date().getTime(); // unixtime
    var challenge = "<" + timestamp + "@" + domain + ">";
    dump("CRAM challenge unencoded: " + challenge + "\n");
    return btoa(challenge);
  },
  /**
   * Takes full CRAM-MD5 auth line, and decodes it.
   *
   * Compare the returned |digest| to the result of
   * encodeCRAMMD5(). If they match, the |username|
   * returned here is authenticated.
   *
   * @param {string} line
   * @returns {object} object
   * @returns {string} object.username
   * @returns {string} object.digest
   */
  decodeLine(line) {
    dump("AUTH CRAM-MD5 line -" + line + "-\n");
    line = atob(line);
    dump("base64 decoded -" + line + "-\n");
    var sp = line.split(" ");
    if (sp.length != 2) {
      throw new Error("Expected one space");
    }
    var result = {};
    result.username = sp[0];
    result.digest = sp[1];
    return result;
  },
  /**
   * @param {string} text - Server challenge (base64-encoded)
   * @param {string} key - User's password
   * @returns {string} digest as hex string
   */
  encodeCRAMMD5(text, key) {
    text = atob(text); // createChallenge() returns it already encoded
    dump("encodeCRAMMD5(text: -" + text + "-, key: -" + key + "-)\n");
    const kInputLen = 64;
    // const kHashLen = 16;
    const kInnerPad = 0x36; // per spec
    const kOuterPad = 0x5c;

    key = this.textToNumberArray(key);
    text = this.textToNumberArray(text);
    // Make sure key is exactly kDigestLen bytes long. Algo per spec.
    if (key.length > kInputLen) {
      // (results in kHashLen)
      key = this.md5(key);
    }
    while (key.length < kInputLen) {
      // Fill up with zeros.
      key.push(0);
    }

    // MD5((key XOR outerpad) + MD5((key XOR innerpad) + text)) , per spec
    var digest = this.md5(
      this.xor(key, kOuterPad).concat(
        this.md5(this.xor(key, kInnerPad).concat(text))
      )
    );
    return this.arrayToHexString(digest);
  },
  // Utils
  xor(binary, value) {
    var result = [];
    for (var i = 0; i < binary.length; i++) {
      result.push(binary[i] ^ value);
    }
    return result;
  },
  md5(binary) {
    var md5 = Cc["@mozilla.org/security/hash;1"].createInstance(
      Ci.nsICryptoHash
    );
    md5.init(Ci.nsICryptoHash.MD5);
    md5.update(binary, binary.length);
    return this.textToNumberArray(md5.finish(false));
  },
  textToNumberArray(text) {
    var array = [];
    for (var i = 0; i < text.length; i++) {
      // Convert string (only lower byte) to array.
      array.push(text.charCodeAt(i) & 0xff);
    }
    return array;
  },
  arrayToHexString(binary) {
    var result = "";
    for (var i = 0; i < binary.length; i++) {
      if (binary[i] > 255) {
        throw new Error("unexpected that value > 255");
      }
      let hex = binary[i].toString(16);
      if (hex.length < 2) {
        hex = "0" + hex;
      }
      result += hex;
    }
    return result;
  },
};
