/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
/* 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/. */

"use strict";

var { Ci, Cc } = require("chrome");
var Services = require("Services");
var promise = require("promise");
var defer = require("devtools/shared/defer");
var DevToolsUtils = require("devtools/shared/DevToolsUtils");
var { dumpn, dumpv } = DevToolsUtils;
loader.lazyRequireGetter(this, "prompt",
  "devtools/shared/security/prompt");
loader.lazyRequireGetter(this, "cert",
  "devtools/shared/security/cert");
loader.lazyRequireGetter(this, "asyncStorage",
  "devtools/shared/async-storage");
const { Task } = require("devtools/shared/task");

/**
 * A simple enum-like object with keys mirrored to values.
 * This makes comparison to a specfic value simpler without having to repeat and
 * mis-type the value.
 */
function createEnum(obj) {
  for (let key in obj) {
    obj[key] = key;
  }
  return obj;
}

/**
 * |allowConnection| implementations can return various values as their |result|
 * field to indicate what action to take.  By specifying these, we can
 * centralize the common actions available, while still allowing embedders to
 * present their UI in whatever way they choose.
 */
var AuthenticationResult = exports.AuthenticationResult = createEnum({

  /**
   * Close all listening sockets, and disable them from opening again.
   */
  DISABLE_ALL: null,

  /**
   * Deny the current connection.
   */
  DENY: null,

  /**
   * Additional data needs to be exchanged before a result can be determined.
   */
  PENDING: null,

  /**
   * Allow the current connection.
   */
  ALLOW: null,

  /**
   * Allow the current connection, and persist this choice for future
   * connections from the same client.  This requires a trustable mechanism to
   * identify the client in the future, such as the cert used during OOB_CERT.
   */
  ALLOW_PERSIST: null

});

/**
 * An |Authenticator| implements an authentication mechanism via various hooks
 * in the client and server debugger socket connection path (see socket.js).
 *
 * |Authenticator|s are stateless objects.  Each hook method is passed the state
 * it needs by the client / server code in socket.js.
 *
 * Separate instances of the |Authenticator| are created for each use (client
 * connection, server listener) in case some methods are customized by the
 * embedder for a given use case.
 */
var Authenticators = {};

/**
 * The Prompt authenticator displays a server-side user prompt that includes
 * connection details, and asks the user to verify the connection.  There are
 * no cryptographic properties at work here, so it is up to the user to be sure
 * that the client can be trusted.
 */
var Prompt = Authenticators.Prompt = {};

Prompt.mode = "PROMPT";

Prompt.Client = function () {};
Prompt.Client.prototype = {

  mode: Prompt.mode,

  /**
   * When client is about to make a new connection, verify that the connection settings
   * are compatible with this authenticator.
   * @throws if validation requirements are not met
   */
  validateSettings() {},

  /**
   * When client has just made a new socket connection, validate the connection
   * to ensure it meets the authenticator's policies.
   *
   * @param host string
   *        The host name or IP address of the debugger server.
   * @param port number
   *        The port number of the debugger server.
   * @param encryption boolean (optional)
   *        Whether the server requires encryption.  Defaults to false.
   * @param cert object (optional)
   *        The server's cert details.
   * @param s nsISocketTransport
   *        Underlying socket transport, in case more details are needed.
   * @return boolean
   *         Whether the connection is valid.
   */
  validateConnection() {
    return true;
  },

  /**
   * Work with the server to complete any additional steps required by this
   * authenticator's policies.
   *
   * Debugging commences after this hook completes successfully.
   *
   * @param host string
   *        The host name or IP address of the debugger server.
   * @param port number
   *        The port number of the debugger server.
   * @param encryption boolean (optional)
   *        Whether the server requires encryption.  Defaults to false.
   * @param transport DebuggerTransport
   *        A transport that can be used to communicate with the server.
   * @return A promise can be used if there is async behavior.
   */
  authenticate() {},

};

Prompt.Server = function () {};
Prompt.Server.prototype = {

  mode: Prompt.mode,

  /**
   * Verify that listener settings are appropriate for this authentication mode.
   *
   * @param listener SocketListener
   *        The socket listener about to be opened.
   * @throws if validation requirements are not met
   */
  validateOptions() {},

  /**
   * Augment options on the listening socket about to be opened.
   *
   * @param listener SocketListener
   *        The socket listener about to be opened.
   * @param socket nsIServerSocket
   *        The socket that is about to start listening.
   */
  augmentSocketOptions() {},

  /**
   * Augment the service discovery advertisement with any additional data needed
   * to support this authentication mode.
   *
   * @param listener SocketListener
   *        The socket listener that was just opened.
   * @param advertisement object
   *        The advertisement being built.
   */
  augmentAdvertisement(listener, advertisement) {
    advertisement.authentication = Prompt.mode;
  },

  /**
   * Determine whether a connection the server should be allowed or not based on
   * this authenticator's policies.
   *
   * @param session object
   *        In PROMPT mode, the |session| includes:
   *        {
   *          client: {
   *            host,
   *            port
   *          },
   *          server: {
   *            host,
   *            port
   *          },
   *          transport
   *        }
   * @return An AuthenticationResult value.
   *         A promise that will be resolved to the above is also allowed.
   */
  authenticate({ client, server }) {
    if (!Services.prefs.getBoolPref("devtools.debugger.prompt-connection")) {
      return AuthenticationResult.ALLOW;
    }
    return this.allowConnection({
      authentication: this.mode,
      client,
      server
    });
  },

  /**
   * Prompt the user to accept or decline the incoming connection.  The default
   * implementation is used unless this is overridden on a particular
   * authenticator instance.
   *
   * It is expected that the implementation of |allowConnection| will show a
   * prompt to the user so that they can allow or deny the connection.
   *
   * @param session object
   *        In PROMPT mode, the |session| includes:
   *        {
   *          authentication: "PROMPT",
   *          client: {
   *            host,
   *            port
   *          },
   *          server: {
   *            host,
   *            port
   *          }
   *        }
   * @return An AuthenticationResult value.
   *         A promise that will be resolved to the above is also allowed.
   */
  allowConnection: prompt.Server.defaultAllowConnection,

};

/**
 * The out-of-band (OOB) cert authenticator is based on self-signed X.509 certs
 * at both the client and server end.
 *
 * The user is first prompted to verify the connection, similar to the prompt
 * method above.  This prompt may display cert fingerprints if desired.
 *
 * Assuming the user approves the connection, further UI is used to assist the
 * user in tranferring out-of-band (OOB) verification of the client's
 * certificate.  For example, this could take the form of a QR code that the
 * client displays which is then scanned by a camera on the server.
 *
 * Since it is assumed that an attacker can't forge the client's X.509 cert, the
 * user may also choose to always allow a client, which would permit immediate
 * connections in the future with no user interaction needed.
 *
 * See docs/wifi.md for details of the authentication design.
 */
var OOBCert = Authenticators.OOBCert = {};

OOBCert.mode = "OOB_CERT";

OOBCert.Client = function () {};
OOBCert.Client.prototype = {

  mode: OOBCert.mode,

  /**
   * When client is about to make a new connection, verify that the connection settings
   * are compatible with this authenticator.
   * @throws if validation requirements are not met
   */
  validateSettings({ encryption }) {
    if (!encryption) {
      throw new Error(`${OOBCert.mode} authentication requires encryption.`);
    }
  },

  /**
   * When client has just made a new socket connection, validate the connection
   * to ensure it meets the authenticator's policies.
   *
   * @param host string
   *        The host name or IP address of the debugger server.
   * @param port number
   *        The port number of the debugger server.
   * @param encryption boolean (optional)
   *        Whether the server requires encryption.  Defaults to false.
   * @param cert object (optional)
   *        The server's cert details.
   * @param socket nsISocketTransport
   *        Underlying socket transport, in case more details are needed.
   * @return boolean
   *         Whether the connection is valid.
   */
  validateConnection({ cert, socket }) {
    // Step B.7
    // Client verifies that Server's cert matches hash(ServerCert) from the
    // advertisement
    dumpv("Validate server cert hash");
    let serverCert = socket.securityInfo.QueryInterface(Ci.nsISSLStatusProvider)
                           .SSLStatus.serverCert;
    let advertisedCert = cert;
    if (serverCert.sha256Fingerprint != advertisedCert.sha256) {
      dumpn("Server cert hash doesn't match advertisement");
      return false;
    }
    return true;
  },

  /**
   * Work with the server to complete any additional steps required by this
   * authenticator's policies.
   *
   * Debugging commences after this hook completes successfully.
   *
   * @param host string
   *        The host name or IP address of the debugger server.
   * @param port number
   *        The port number of the debugger server.
   * @param encryption boolean (optional)
   *        Whether the server requires encryption.  Defaults to false.
   * @param cert object (optional)
   *        The server's cert details.
   * @param transport DebuggerTransport
   *        A transport that can be used to communicate with the server.
   * @return A promise can be used if there is async behavior.
   */
  authenticate({ host, port, cert, transport }) {
    let deferred = defer();
    let oobData;

    let activeSendDialog;
    let closeDialog = () => {
      // Close any prompts the client may have been showing from previous
      // authentication steps
      if (activeSendDialog && activeSendDialog.close) {
        activeSendDialog.close();
        activeSendDialog = null;
      }
    };

    transport.hooks = {
      onPacket: Task.async(function* (packet) {
        closeDialog();
        let { authResult } = packet;
        switch (authResult) {
          case AuthenticationResult.PENDING:
            // Step B.8
            // Client creates hash(ClientCert) + K(random 128-bit number)
            oobData = yield this._createOOB();
            activeSendDialog = this.sendOOB({
              host,
              port,
              cert,
              authResult,
              oob: oobData
            });
            break;
          case AuthenticationResult.ALLOW:
            // Step B.12
            // Client verifies received value matches K
            if (packet.k != oobData.k) {
              transport.close(new Error("Auth secret mismatch"));
              return;
            }
            // Step B.13
            // Debugging begins
            transport.hooks = null;
            deferred.resolve(transport);
            break;
          case AuthenticationResult.ALLOW_PERSIST:
            // Server previously persisted Client as allowed
            // Step C.5
            // Debugging begins
            transport.hooks = null;
            deferred.resolve(transport);
            break;
          default:
            transport.close(new Error("Invalid auth result: " + authResult));
            return;
        }
      }.bind(this)),
      onClosed(reason) {
        closeDialog();
        // Transport died before auth completed
        transport.hooks = null;
        deferred.reject(reason);
      }
    };
    transport.ready();
    return deferred.promise;
  },

  /**
   * Create the package of data that needs to be transferred across the OOB
   * channel.
   */
  _createOOB: Task.async(function* () {
    let clientCert = yield cert.local.getOrCreate();
    return {
      sha256: clientCert.sha256Fingerprint,
      k: this._createRandom()
    };
  }),

  _createRandom() {
    const length = 16; // 16 bytes / 128 bits
    let rng = Cc["@mozilla.org/security/random-generator;1"]
              .createInstance(Ci.nsIRandomGenerator);
    let bytes = rng.generateRandomBytes(length);
    return bytes.map(byte => byte.toString(16)).join("");
  },

  /**
   * Send data across the OOB channel to the server to authenticate the devices.
   *
   * @param host string
   *        The host name or IP address of the debugger server.
   * @param port number
   *        The port number of the debugger server.
   * @param cert object (optional)
   *        The server's cert details.
   * @param authResult AuthenticationResult
   *        Authentication result sent from the server.
   * @param oob object (optional)
   *        The token data to be transferred during OOB_CERT step 8:
   *        * sha256: hash(ClientCert)
   *        * k     : K(random 128-bit number)
   * @return object containing:
   *         * close: Function to hide the notification
   */
  sendOOB: prompt.Client.defaultSendOOB,

};

OOBCert.Server = function () {};
OOBCert.Server.prototype = {

  mode: OOBCert.mode,

  /**
   * Verify that listener settings are appropriate for this authentication mode.
   *
   * @param listener SocketListener
   *        The socket listener about to be opened.
   * @throws if validation requirements are not met
   */
  validateOptions(listener) {
    if (!listener.encryption) {
      throw new Error(OOBCert.mode + " authentication requires encryption.");
    }
  },

  /**
   * Augment options on the listening socket about to be opened.
   *
   * @param listener SocketListener
   *        The socket listener about to be opened.
   * @param socket nsIServerSocket
   *        The socket that is about to start listening.
   */
  augmentSocketOptions(listener, socket) {
    let requestCert = Ci.nsITLSServerSocket.REQUIRE_ALWAYS;
    socket.setRequestClientCertificate(requestCert);
  },

  /**
   * Augment the service discovery advertisement with any additional data needed
   * to support this authentication mode.
   *
   * @param listener SocketListener
   *        The socket listener that was just opened.
   * @param advertisement object
   *        The advertisement being built.
   */
  augmentAdvertisement(listener, advertisement) {
    advertisement.authentication = OOBCert.mode;
    // Step A.4
    // Server announces itself via service discovery
    // Announcement contains hash(ServerCert) as additional data
    advertisement.cert = listener.cert;
  },

  /**
   * Determine whether a connection the server should be allowed or not based on
   * this authenticator's policies.
   *
   * @param session object
   *        In OOB_CERT mode, the |session| includes:
   *        {
   *          client: {
   *            host,
   *            port,
   *            cert: {
   *              sha256
   *            },
   *          },
   *          server: {
   *            host,
   *            port,
   *            cert: {
   *              sha256
   *            }
   *          },
   *          transport
   *        }
   * @return An AuthenticationResult value.
   *         A promise that will be resolved to the above is also allowed.
   */
  authenticate: Task.async(function* ({ client, server, transport }) {
    // Step B.3 / C.3
    // TLS connection established, authentication begins
    const storageKey = `devtools.auth.${this.mode}.approved-clients`;
    let approvedClients = (yield asyncStorage.getItem(storageKey)) || {};
    // Step C.4
    // Server sees that ClientCert is from a known client via hash(ClientCert)
    if (approvedClients[client.cert.sha256]) {
      let authResult = AuthenticationResult.ALLOW_PERSIST;
      transport.send({ authResult });
      // Step C.5
      // Debugging begins
      return authResult;
    }

    // Step B.4
    // Server sees that ClientCert is from a unknown client
    // Tell client they are unknown and should display OOB client UX
    transport.send({
      authResult: AuthenticationResult.PENDING
    });

    // Step B.5
    // User is shown a Allow / Deny / Always Allow prompt on the Server
    // with Client name and hash(ClientCert)
    let authResult = yield this.allowConnection({
      authentication: this.mode,
      client,
      server
    });

    switch (authResult) {
      case AuthenticationResult.ALLOW_PERSIST:
      case AuthenticationResult.ALLOW:
        break; // Further processing
      default:
        return authResult; // Abort for any negative results
    }

    // Examine additional data for authentication
    let oob = yield this.receiveOOB();
    if (!oob) {
      dumpn("Invalid OOB data received");
      return AuthenticationResult.DENY;
    }

    let { sha256, k } = oob;
    // The OOB auth prompt should have transferred:
    // hash(ClientCert) + K(random 128-bit number)
    // from the client.
    if (!sha256 || !k) {
      dumpn("Invalid OOB data received");
      return AuthenticationResult.DENY;
    }

    // Step B.10
    // Server verifies that Client's cert matches hash(ClientCert) from
    // out-of-band channel
    if (client.cert.sha256 != sha256) {
      dumpn("Client cert hash doesn't match OOB data");
      return AuthenticationResult.DENY;
    }

    // Step B.11
    // Server sends K to Client over TLS connection
    transport.send({ authResult, k });

    // Persist Client if we want to always allow in the future
    if (authResult === AuthenticationResult.ALLOW_PERSIST) {
      approvedClients[client.cert.sha256] = true;
      yield asyncStorage.setItem(storageKey, approvedClients);
    }

    // Client may decide to abort if K does not match.
    // Server's portion of authentication is now complete.

    // Step B.13
    // Debugging begins
    return authResult;
  }),

  /**
   * Prompt the user to accept or decline the incoming connection. The default
   * implementation is used unless this is overridden on a particular
   * authenticator instance.
   *
   * It is expected that the implementation of |allowConnection| will show a
   * prompt to the user so that they can allow or deny the connection.
   *
   * @param session object
   *        In OOB_CERT mode, the |session| includes:
   *        {
   *          authentication: "OOB_CERT",
   *          client: {
   *            host,
   *            port,
   *            cert: {
   *              sha256
   *            },
   *          },
   *          server: {
   *            host,
   *            port,
   *            cert: {
   *              sha256
   *            }
   *          }
   *        }
   * @return An AuthenticationResult value.
   *         A promise that will be resolved to the above is also allowed.
   */
  allowConnection: prompt.Server.defaultAllowConnection,

  /**
   * The user must transfer some data through some out of band mechanism from
   * the client to the server to authenticate the devices.
   *
   * @return An object containing:
   *         * sha256: hash(ClientCert)
   *         * k     : K(random 128-bit number)
   *         A promise that will be resolved to the above is also allowed.
   */
  receiveOOB: prompt.Server.defaultReceiveOOB,

};

exports.Authenticators = {
  get(mode) {
    if (!mode) {
      mode = Prompt.mode;
    }
    for (let key in Authenticators) {
      let auth = Authenticators[key];
      if (auth.mode === mode) {
        return auth;
      }
    }
    throw new Error("Unknown authenticator mode: " + mode);
  }
};
