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

const Cc = Components.classes;
const Ci = Components.interfaces;
const Cr = Components.results;
const Cu = Components.utils;

// COMPLETE_LENGTH and PARTIAL_LENGTH copied from nsUrlClassifierDBService.h,
// they correspond to the length, in bytes, of a hash prefix and the total
// hash.
const COMPLETE_LENGTH = 32;
const PARTIAL_LENGTH = 4;

// These backoff related constants are taken from v2 of the Google Safe Browsing
// API.
// BACKOFF_ERRORS: the number of errors incurred until we start to back off.
// BACKOFF_INTERVAL: the initial time, in seconds, to wait once we start backing
//                   off.
// BACKOFF_MAX: as the backoff time doubles after each failure, this is a
//              ceiling on the time to wait, in seconds.
// BACKOFF_TIME: length of the interval of time, in seconds, during which errors
//               are taken into account.

const BACKOFF_ERRORS = 2;
const BACKOFF_INTERVAL = 30 * 60;
const BACKOFF_MAX = 8 * 60 * 60;
const BACKOFF_TIME = 5 * 60;

Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
let keyFactory = Cc["@mozilla.org/security/keyobjectfactory;1"]
                   .getService(Ci.nsIKeyObjectFactory);

function HashCompleter() {
  // This is a HashCompleterRequest and is used by multiple calls to |complete|
  // in succession to avoid unnecessarily creating requests. Once it has been
  // started, this is set to null again.
  this._currentRequest = null;

  // Key used in the HMAC process by the client to verify the request has not
  // been tampered with. It is stored as a binary blob.
  this._clientKey = "";
  // Key used in the HMAC process and sent remotely to the server in the URL
  // of the request. It is stored as a base64 string.
  this._wrappedKey = "";

  // Whether we have been informed of a shutdown by the xpcom-shutdown event.
  this._shuttingDown = false;

  // All of these backoff properties are different per completer as the DB
  // service keeps one completer per table.
  //
  // _backoff tells us whether we are "backing off" from making requests.
  // It is set in |noteServerResponse| and set after a number of failures.
  this._backoff = false;
  // _backoffTime tells us how long we should wait (in seconds) before making
  // another request.
  this._backoffTime = 0;
  // _nextRequestTime is the earliest time at which we are allowed to make
  // another request by the backoff policy. It is measured in milliseconds.
  this._nextRequestTime = 0;
  // A list of the times at which a failed request was made, recorded in
  // |noteServerResponse|. Sorted by oldest to newest and its length is clamped
  // by BACKOFF_ERRORS.
  this._errorTimes = [];

  Services.obs.addObserver(this, "xpcom-shutdown", true);
}
HashCompleter.prototype = {
  classID: Components.ID("{9111de73-9322-4bfc-8b65-2b727f3e6ec8}"),
  QueryInterface: XPCOMUtils.generateQI([Ci.nsIUrlClassifierHashCompleter,
                                         Ci.nsIRunnable,
                                         Ci.nsIObserver,
                                         Ci.nsISupportsWeakReference,
                                         Ci.nsISupports]),

  // This is mainly how the HashCompleter interacts with other components.
  // Even though it only takes one partial hash and callback, subsequent
  // calls are made into the same HTTP request by using a thread dispatch.
  complete: function HC_complete(aPartialHash, aCallback) {
    if (!this._currentRequest) {
      this._currentRequest = new HashCompleterRequest(this);

      // It's possible for getHashUrl to not be set, usually at start-up.
      if (this._getHashUrl) {
        Services.tm.currentThread.dispatch(this, Ci.nsIThread.DISPATCH_NORMAL);
      }
    }

    this._currentRequest.add(aPartialHash, aCallback);
  },

  // This is called when either the getHashUrl has been set or after a few calls
  // to |complete|. It starts off the HTTP request by making a |begin| call
  // to the HashCompleterRequest.
  run: function HC_run() {
    if (this._shuttingDown) {
      this._currentRequest = null;
      throw Cr.NS_ERROR_NOT_INITIALIZED;
    }

    if (!this._currentRequest) {
      return;
    }

    if (!this._getHashUrl) {
      throw Cr.NS_ERROR_NOT_INITIALIZED;
    }

    let url = this._getHashUrl;
    if (this._clientKey) {
      this._currentRequest.clientKey = this._clientKey;
      url += "&wrkey=" + this._wrappedKey;
    }

    let uri = Services.io.newURI(url, null, null);
    this._currentRequest.setURI(uri);

    // If |begin| fails, we should get rid of our request.
    try {
      this._currentRequest.begin();
    }
    finally {
      this._currentRequest = null;
    }
  },

  // When a rekey has been requested, we can only clear our keys and make
  // unauthenticated requests.
  // The HashCompleter does not handle the rekeying but instead sends a
  // notification to have listeners do the work.
  rekeyRequested: function HC_rekeyRequested() {
    this.setKeys("", "");

    Services.obs.notifyObservers(this, "url-classifier-rekey-requested", null);
  },

  // setKeys expects clientKey and wrappedKey to be url safe, base64 strings.
  // When called with an empty client string, setKeys resets both the client
  // key and wrapped key.
  setKeys: function HC_setKeys(aClientKey, aWrappedKey) {
    if (aClientKey == "") {
      this._clientKey = "";
      this._wrappedKey = "";
      return;
    }

    // The decoding of clientKey was originally done by using
    // nsUrlClassifierUtils::DecodeClientKey.
    this._clientKey = atob(unUrlsafeBase64(aClientKey));
    this._wrappedKey = aWrappedKey;
  },

  get gethashUrl() {
    return this._getHashUrl;
  },
  // Because we hold off on making a request until we have a valid getHashUrl,
  // we kick off the process here.
  set gethashUrl(aNewUrl) {
    this._getHashUrl = aNewUrl;

    if (this._currentRequest) {
      Services.tm.currentThread.dispatch(this, Ci.nsIThread.DISPATCH_NORMAL);
    }
  },

  // This handles all the logic about setting a back off time based on
  // server responses. It should only be called once in the life time of a
  // request.
  // The logic behind backoffs is documented in the Google Safe Browsing API,
  // the general idea is that a completer should go into backoff mode after
  // BACKOFF_ERRORS errors in the last BACKOFF_TIME seconds. From there,
  // we do not make a request for BACKOFF_INTERVAL seconds and for every failed
  // request after that we double how long to wait, to a maximum of BACKOFF_MAX.
  // Note that even in the case of a successful response we still keep a history
  // of past errors.
  noteServerResponse: function HC_noteServerResponse(aSuccess) {
    if (aSuccess) {
      this._backoff = false;
      this._nextRequestTime = 0;
      this._backoffTime = 0;
      return;
    }

    let now = Date.now();

    // We only alter the size of |_errorTimes| here, so we can guarantee that
    // its length is at most BACKOFF_ERRORS.
    this._errorTimes.push(now);
    if (this._errorTimes.length > BACKOFF_ERRORS) {
      this._errorTimes.shift();
    }

    if (this._backoff) {
      this._backoffTime *= 2;
    } else if (this._errorTimes.length == BACKOFF_ERRORS &&
               ((now - this._errorTimes[0]) / 1000) <= BACKOFF_TIME) {
      this._backoff = true;
      this._backoffTime = BACKOFF_INTERVAL;
    }

    if (this._backoff) {
      this._backoffTime = Math.min(this._backoffTime, BACKOFF_MAX);
      this._nextRequestTime = now + (this._backoffTime * 1000);
    }
  },

  // This is not included on the interface but is used to communicate to the
  // HashCompleterRequest. It returns a time in milliseconds.
  get nextRequestTime() {
    return this._nextRequestTime;
  },

  observe: function HC_observe(aSubject, aTopic, aData) {
    if (aTopic == "xpcom-shutdown") {
      this._shuttingDown = true;
    }
  },
};

function HashCompleterRequest(aCompleter) {
  // HashCompleter object that created this HashCompleterRequest.
  this._completer = aCompleter;
  // The internal set of hashes and callbacks that this request corresponds to.
  this._requests = [];
  // URI to query for hash completions. Largely comes from the
  // browser.safebrowsing.gethashURL pref.
  this._uri = null;
  // nsIChannel that the hash completion query is transmitted over.
  this._channel = null;
  // Response body of hash completion. Created in onDataAvailable.
  this._response = "";
  // Client key when HMAC is used.
  this._clientKey = "";
  // Request was rescheduled, possibly due to a "e:pleaserekey" request from
  // the server.
  this._rescheduled = false;
  // Whether the request was encrypted. This is also used as the |trusted|
  // parameter to the nsIUrlClassifierHashCompleterCallback.
  this._verified = false;
  // Whether we have been informed of a shutdown by the xpcom-shutdown event.
  this._shuttingDown = false;
}
HashCompleterRequest.prototype = {
  QueryInterface: XPCOMUtils.generateQI([Ci.nsIRequestObserver,
                                         Ci.nsIStreamListener,
                                         Ci.nsIObserver,
                                         Ci.nsISupports]),

  // This is called by the HashCompleter to add a hash and callback to the
  // HashCompleterRequest. It must be called before calling |begin|.
  add: function HCR_add(aPartialHash, aCallback) {
    this._requests.push({
      partialHash: aPartialHash,
      callback: aCallback,
      responses: [],
    });
  },

  // This initiates the HTTP request. It can fail due to backoff timings and
  // will notify all callbacks as necessary.
  begin: function HCR_begin() {
    if (Date.now() < this._completer.nextRequestTime) {
      this.notifyFailure(Cr.NS_ERROR_ABORT);
      return;
    }

    Services.obs.addObserver(this, "xpcom-shutdown", false);

    try {
      this.openChannel();
    }
    catch (err) {
      this.notifyFailure(err);
      throw err;
    }
  },

  setURI: function HCR_setURI(aURI) {
    this._uri = aURI;
  },

  // Creates an nsIChannel for the request and fills the body.
  openChannel: function HCR_openChannel() {
    let loadFlags = Ci.nsIChannel.INHIBIT_CACHING |
                    Ci.nsIChannel.LOAD_BYPASS_CACHE;

    let channel = Services.io.newChannelFromURI(this._uri);
    channel.loadFlags = loadFlags;

    this._channel = channel;

    let body = this.buildRequest();
    this.addRequestBody(body);

    channel.asyncOpen(this, null);
  },

  // Returns a string for the request body based on the contents of
  // this._requests.
  buildRequest: function HCR_buildRequest() {
    // Sometimes duplicate entries are sent to HashCompleter but we do not need
    // to propagate these to the server. (bug 633644)
    let prefixes = [];

    for (let i = 0; i < this._requests.length; i++) {
      let request = this._requests[i];
      if (prefixes.indexOf(request.partialHash) == -1) {
        prefixes.push(request.partialHash);
      }
    }

    let body;
    body = PARTIAL_LENGTH + ":" + (PARTIAL_LENGTH * prefixes.length) +
           "\n" + prefixes.join("");

    return body;
  },

  // Sets the request body of this._channel.
  addRequestBody: function HCR_addRequestBody(aBody) {
    let inputStream = Cc["@mozilla.org/io/string-input-stream;1"].
                      createInstance(Ci.nsIStringInputStream);

    inputStream.setData(aBody, aBody.length);

    let uploadChannel = this._channel.QueryInterface(Ci.nsIUploadChannel);
    uploadChannel.setUploadStream(inputStream, "text/plain", -1);

    let httpChannel = this._channel.QueryInterface(Ci.nsIHttpChannel);
    httpChannel.requestMethod = "POST";
  },

  // Parses the response body and eventually adds items to the |responses| array
  // for elements of |this._requests|.
  handleResponse: function HCR_handleResponse() {
    if (this._response == "") {
      return;
    }

    let start = 0;
    if (this._clientKey) {
      start = this.handleMAC(start);

      if (this._rescheduled) {
        return;
      }
    }

    let length = this._response.length;
    while (start != length)
      start = this.handleTable(start);
  },

  // This parses and confirms that the MAC in the response matches the expected
  // value. This throws an error if the MAC does not match or otherwise, returns
  // the index after the MAC header.
  handleMAC: function HCR_handleMAC(aStart) {
    this._verified = false;

    let body = this._response.substring(aStart);

    // We have to deal with the index of the new line character instead of
    // splitting the string as there could be new line characters in the data
    // parts.
    let newlineIndex = body.indexOf("\n");
    if (newlineIndex == -1) {
      throw errorWithStack();
    }

    let serverMAC = body.substring(0, newlineIndex);
    if (serverMAC == "e:pleaserekey") {
      this.rescheduleItems();

      this._completer.rekeyRequested();
      return this._response.length;
    }

    serverMAC = unUrlsafeBase64(serverMAC);

    let keyObject = keyFactory.keyFromString(Ci.nsIKeyObject.HMAC,
                                             this._clientKey);

    let data = body.substring(newlineIndex + 1).split("")
                                              .map(function(x) x.charCodeAt(0));

    let hmac = Cc["@mozilla.org/security/hmac;1"]
                 .createInstance(Ci.nsICryptoHMAC);
    hmac.init(Ci.nsICryptoHMAC.SHA1, keyObject);
    hmac.update(data, data.length);
    let clientMAC = hmac.finish(true);

    if (clientMAC != serverMAC) {
      throw errorWithStack();
    }

    this._verified = true;

    return aStart + newlineIndex + 1;
  },

  // This parses a table entry in the response body and calls |handleItem|
  // for complete hash in the table entry. Like |handleMAC|, it returns the
  // index in |_response| right after the table it parsed.
  handleTable: function HCR_handleTable(aStart) {
    let body = this._response.substring(aStart);

    // Like in handleMAC, we deal with new line indexes as there could be
    // new line characters in the data parts.
    let newlineIndex = body.indexOf("\n");
    if (newlineIndex == -1) {
      throw errorWithStack();
    }
    let header = body.substring(0, newlineIndex);
    let entries = header.split(":");
    if (entries.length != 3) {
      throw errorWithStack();
    }

    let list = entries[0];
    let addChunk = parseInt(entries[1]);
    let dataLength = parseInt(entries[2]);

    if (dataLength % COMPLETE_LENGTH != 0 ||
        dataLength == 0 ||
        dataLength > body.length - (newlineIndex + 1)) {
      throw errorWithStack();
    }

    let data = body.substr(newlineIndex + 1, dataLength);
    for (let i = 0; i < (dataLength / COMPLETE_LENGTH); i++) {
      this.handleItem(data.substr(i * COMPLETE_LENGTH, COMPLETE_LENGTH), list,
                      addChunk);
    }

    return aStart + newlineIndex + 1 + dataLength;
  },

  // This adds a complete hash to any entry in |this._requests| that matches
  // the hash.
  handleItem: function HCR_handleItem(aData, aTableName, aChunkId) {
    for (let i = 0; i < this._requests.length; i++) {
      let request = this._requests[i];
      if (aData.substring(0,4) == request.partialHash) {
        request.responses.push({
          completeHash: aData,
          tableName: aTableName,
          chunkId: aChunkId,
        });
      }
    }
  },

  // notifySuccess and notifyFailure are used to alert the callbacks with
  // results. notifySuccess makes |completion| and |completionFinished| calls
  // while notifyFailure only makes a |completionFinished| call with the error
  // code.
  notifySuccess: function HCR_notifySuccess() {
    for (let i = 0; i < this._requests.length; i++) {
      let request = this._requests[i];
      for (let j = 0; j < request.responses.length; j++) {
        let response = request.responses[j];
        request.callback.completion(response.completeHash, response.tableName,
                                    response.chunkId, this._verified);
      }

      request.callback.completionFinished(Cr.NS_OK);
    }
  },
  notifyFailure: function HCR_notifyFailure(aStatus) {
    for (let i = 0; i < this._requests; i++) {
      let request = this._requests[i];
      request.callback.completionFinished(aStatus);
    }
  },

  // rescheduleItems is called after a "e:pleaserekey" response. It is meant
  // to be called after |rekeyRequested| has been called as it re-calls
  // the HashCompleter with |complete| for all the items on this request.
  rescheduleItems: function HCR_rescheduleItems() {
    for (let i = 0; i < this._requests[i]; i++) {
      let request = this._requests[i];
      try {
        this._completer.complete(request.partialHash, request.callback);
      }
      catch (err) {
        request.callback.completionFinished(err);
      }
    }

    this._rescheduled = true;
  },

  onDataAvailable: function HCR_onDataAvailable(aRequest, aContext,
                                                aInputStream, aOffset, aCount) {
    let sis = Cc["@mozilla.org/scriptableinputstream;1"].
              createInstance(Ci.nsIScriptableInputStream);
    sis.init(aInputStream);
    this._response += sis.readBytes(aCount);
  },

  onStartRequest: function HCR_onStartRequest(aRequest, aContext) {
    // At this point no data is available for us and we have no reason to
    // terminate the connection, so we do nothing until |onStopRequest|.
  },

  onStopRequest: function HCR_onStopRequest(aRequest, aContext, aStatusCode) {
    Services.obs.removeObserver(this, "xpcom-shutdown");

    if (this._shuttingDown) {
      throw Cr.NS_ERROR_ABORT;
    }

    if (Components.isSuccessCode(aStatusCode)) {
      let channel = aRequest.QueryInterface(Ci.nsIHttpChannel);
      let success = channel.requestSucceeded;
      if (!success) {
        aStatusCode = Cr.NS_ERROR_ABORT;
      }
    }

    let success = Components.isSuccessCode(aStatusCode);
    this._completer.noteServerResponse(success);

    if (success) {
      try {
        this.handleResponse();
      }
      catch (err) {
        dump(err.stack);
        aStatusCode = err.value;
        success = false;
      }
    }

    if (!this._rescheduled) {
      if (success) {
        this.notifySuccess();
      } else {
        this.notifyFailure(aStatusCode);
      }
    }
  },

  set clientKey(aVal) {
    this._clientKey = aVal;
  },

  observe: function HCR_observe(aSubject, aTopic, aData) {
    if (aTopic != "xpcom-shutdown") {
      return;
    }

    this._shuttingDown = true;
    if (this._channel) {
      this._channel.cancel(Cr.NS_ERROR_ABORT);
    }
  },
};

// Converts a URL safe base64 string to a normal base64 string. Will not change
// normal base64 strings. This is modelled after the same function in
// nsUrlClassifierUtils.h.
function unUrlsafeBase64(aStr) {
  return !aStr ? "" : aStr.replace(/-/g, "+")
                          .replace(/_/g, "/");
}

function errorWithStack() {
  let err = new Error();
  err.value = Cr.NS_ERROR_FAILURE;
  return err;
}

this.NSGetFactory = XPCOMUtils.generateNSGetFactory([HashCompleter]);
