tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

hawkclient.sys.mjs (11387B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 /*
      6 * HAWK is an HTTP authentication scheme using a message authentication code
      7 * (MAC) algorithm to provide partial HTTP request cryptographic verification.
      8 *
      9 * For details, see: https://github.com/hueniverse/hawk
     10 *
     11 * With HAWK, it is essential that the clocks on clients and server not have an
     12 * absolute delta of greater than one minute, as the HAWK protocol uses
     13 * timestamps to reduce the possibility of replay attacks.  However, it is
     14 * likely that some clients' clocks will be more than a little off, especially
     15 * in mobile devices, which would break HAWK-based services (like sync and
     16 * firefox accounts) for those clients.
     17 *
     18 * This library provides a stateful HAWK client that calculates (roughly) the
     19 * clock delta on the client vs the server.  The library provides an interface
     20 * for deriving HAWK credentials and making HAWK-authenticated REST requests to
     21 * a single remote server.  Therefore, callers who want to interact with
     22 * multiple HAWK services should instantiate one HawkClient per service.
     23 */
     24 
     25 import { HAWKAuthenticatedRESTRequest } from "resource://services-common/hawkrequest.sys.mjs";
     26 
     27 import { Observers } from "resource://services-common/observers.sys.mjs";
     28 import { Log } from "resource://gre/modules/Log.sys.mjs";
     29 
     30 // log.appender.dump should be one of "Fatal", "Error", "Warn", "Info", "Config",
     31 // "Debug", "Trace" or "All". If none is specified, "Error" will be used by
     32 // default.
     33 // Note however that Sync will also add this log to *its* DumpAppender, so
     34 // in a Sync context it shouldn't be necessary to adjust this - however, that
     35 // also means error logs are likely to be dump'd twice but that's OK.
     36 const PREF_LOG_LEVEL = "services.common.hawk.log.appender.dump";
     37 
     38 // A pref that can be set so "sensitive" information (eg, personally
     39 // identifiable info, credentials, etc) will be logged.
     40 const PREF_LOG_SENSITIVE_DETAILS = "services.common.hawk.log.sensitive";
     41 
     42 const lazy = {};
     43 
     44 ChromeUtils.defineLazyGetter(lazy, "log", function () {
     45  let log = Log.repository.getLogger("Hawk");
     46  // We set the log itself to "debug" and set the level from the preference to
     47  // the appender.  This allows other things to send the logs to different
     48  // appenders, while still allowing the pref to control what is seen via dump()
     49  log.level = Log.Level.Debug;
     50  let appender = new Log.DumpAppender();
     51  log.addAppender(appender);
     52  appender.level = Log.Level.Error;
     53  try {
     54    let level =
     55      Services.prefs.getPrefType(PREF_LOG_LEVEL) ==
     56        Ci.nsIPrefBranch.PREF_STRING &&
     57      Services.prefs.getStringPref(PREF_LOG_LEVEL);
     58    appender.level = Log.Level[level] || Log.Level.Error;
     59  } catch (e) {
     60    log.error(e);
     61  }
     62 
     63  return log;
     64 });
     65 
     66 // A boolean to indicate if personally identifiable information (or anything
     67 // else sensitive, such as credentials) should be logged.
     68 ChromeUtils.defineLazyGetter(lazy, "logPII", function () {
     69  try {
     70    return Services.prefs.getBoolPref(PREF_LOG_SENSITIVE_DETAILS);
     71  } catch (_) {
     72    return false;
     73  }
     74 });
     75 
     76 /**
     77 * A general purpose client for making HAWK authenticated requests to a single
     78 * host.  Keeps track of the clock offset between the client and the host for
     79 * computation of the timestamp in the HAWK Authorization header.
     80 *
     81 * Clients should create one HawkClient object per each server they wish to
     82 * interact with.
     83 *
     84 * @param host
     85 *        The url of the host
     86 */
     87 export var HawkClient = function (host) {
     88  this.host = host;
     89 
     90  // Clock offset in milliseconds between our client's clock and the date
     91  // reported in responses from our host.
     92  this._localtimeOffsetMsec = 0;
     93 };
     94 
     95 HawkClient.prototype = {
     96  /**
     97   * Construct an error message for a response.  Private.
     98   *
     99   * @param restResponse
    100   *        A RESTResponse object from a RESTRequest
    101   *
    102   * @param error
    103   *        A string or object describing the error
    104   */
    105  _constructError(restResponse, error) {
    106    let errorObj = {
    107      error,
    108      // This object is likely to be JSON.stringify'd, but neither Error()
    109      // objects nor Components.Exception objects do the right thing there,
    110      // so we add a new element which is simply the .toString() version of
    111      // the error object, so it does appear in JSON'd values.
    112      errorString: error.toString(),
    113      message: restResponse.statusText,
    114      code: restResponse.status,
    115      errno: restResponse.status,
    116      toString() {
    117        return this.code + ": " + this.message;
    118      },
    119    };
    120    let retryAfter =
    121      restResponse.headers && restResponse.headers["retry-after"];
    122    retryAfter = retryAfter ? parseInt(retryAfter) : retryAfter;
    123    if (retryAfter) {
    124      errorObj.retryAfter = retryAfter;
    125      // and notify observers of the retry interval
    126      if (this.observerPrefix) {
    127        Observers.notify(this.observerPrefix + ":backoff:interval", retryAfter);
    128      }
    129    }
    130    return errorObj;
    131  },
    132 
    133  /**
    134   *
    135   * Update clock offset by determining difference from date gives in the (RFC
    136   * 1123) Date header of a server response.  Because HAWK tolerates a window
    137   * of one minute of clock skew (so two minutes total since the skew can be
    138   * positive or negative), the simple method of calculating offset here is
    139   * probably good enough.  We keep the value in milliseconds to make life
    140   * easier, even though the value will not have millisecond accuracy.
    141   *
    142   * @param dateString
    143   *        An RFC 1123 date string (e.g., "Mon, 13 Jan 2014 21:45:06 GMT")
    144   *
    145   * For HAWK clock skew and replay protection, see
    146   * https://github.com/hueniverse/hawk#replay-protection
    147   */
    148  _updateClockOffset(dateString) {
    149    try {
    150      let serverDateMsec = Date.parse(dateString);
    151      this._localtimeOffsetMsec = serverDateMsec - this.now();
    152      lazy.log.debug(
    153        "Clock offset vs " + this.host + ": " + this._localtimeOffsetMsec
    154      );
    155    } catch (err) {
    156      lazy.log.warn("Bad date header in server response: " + dateString);
    157    }
    158  },
    159 
    160  /*
    161   * Get the current clock offset in milliseconds.
    162   *
    163   * The offset is the number of milliseconds that must be added to the client
    164   * clock to make it equal to the server clock.  For example, if the client is
    165   * five minutes ahead of the server, the localtimeOffsetMsec will be -300000.
    166   */
    167  get localtimeOffsetMsec() {
    168    return this._localtimeOffsetMsec;
    169  },
    170 
    171  /*
    172   * return current time in milliseconds
    173   */
    174  now() {
    175    return Date.now();
    176  },
    177 
    178  /**
    179   * A general method for sending raw RESTRequest calls authorized using HAWK.
    180   *
    181   * @param path
    182   *        API endpoint path
    183   * @param method
    184   *        The HTTP request method
    185   * @param credentials
    186   *        Hawk credentials
    187   * @param payloadObj
    188   *        An object that can be encodable as JSON as the payload of the
    189   *        request
    190   * @param extraHeaders
    191   *        An object with header/value pairs to send with the request.
    192   * @return Promise
    193   *        Returns a promise that resolves to the response of the API call,
    194   *        or is rejected with an error.  If the server response can be parsed
    195   *        as JSON and contains an 'error' property, the promise will be
    196   *        rejected with this JSON-parsed response.
    197   */
    198  async request(
    199    path,
    200    method,
    201    credentials = null,
    202    payloadObj = {},
    203    extraHeaders = {},
    204    retryOK = true
    205  ) {
    206    method = method.toLowerCase();
    207 
    208    let uri = this.host + path;
    209 
    210    let extra = {
    211      now: this.now(),
    212      localtimeOffsetMsec: this.localtimeOffsetMsec,
    213      headers: extraHeaders,
    214    };
    215 
    216    let request = this.newHAWKAuthenticatedRESTRequest(uri, credentials, extra);
    217    let error;
    218    let restResponse = await request[method](payloadObj).catch(e => {
    219      // Keep a reference to the error, log a message about it, and return the
    220      // response anyway.
    221      error = e;
    222      lazy.log.warn("hawk request error", error);
    223      return request.response;
    224    });
    225 
    226    // This shouldn't happen anymore, but it's not exactly difficult to handle.
    227    if (!restResponse) {
    228      throw error;
    229    }
    230 
    231    let status = restResponse.status;
    232 
    233    lazy.log.debug(
    234      "(Response) " +
    235        path +
    236        ": code: " +
    237        status +
    238        " - Status text: " +
    239        restResponse.statusText
    240    );
    241    if (lazy.logPII) {
    242      lazy.log.debug("Response text", restResponse.body);
    243    }
    244 
    245    // All responses may have backoff headers, which are a server-side safety
    246    // valve to allow slowing down clients without hurting performance.
    247    this._maybeNotifyBackoff(restResponse, "x-weave-backoff");
    248    this._maybeNotifyBackoff(restResponse, "x-backoff");
    249 
    250    if (error) {
    251      // When things really blow up, reconstruct an error object that follows
    252      // the general format of the server on error responses.
    253      throw this._constructError(restResponse, error);
    254    }
    255 
    256    this._updateClockOffset(restResponse.headers.date);
    257 
    258    if (status === 401 && retryOK && !("retry-after" in restResponse.headers)) {
    259      // Retry once if we were rejected due to a bad timestamp.
    260      // Clock offset is adjusted already in the top of this function.
    261      lazy.log.debug("Received 401 for " + path + ": retrying");
    262      return this.request(
    263        path,
    264        method,
    265        credentials,
    266        payloadObj,
    267        extraHeaders,
    268        false
    269      );
    270    }
    271 
    272    // If the server returned a json error message, use it in the rejection
    273    // of the promise.
    274    //
    275    // In the case of a 401, in which we are probably being rejected for a
    276    // bad timestamp, retry exactly once, during which time clock offset will
    277    // be adjusted.
    278 
    279    let jsonResponse = {};
    280    try {
    281      jsonResponse = JSON.parse(restResponse.body);
    282    } catch (notJSON) {}
    283 
    284    let okResponse = 200 <= status && status < 300;
    285    if (!okResponse || jsonResponse.error) {
    286      if (jsonResponse.error) {
    287        throw jsonResponse;
    288      }
    289      throw this._constructError(restResponse, "Request failed");
    290    }
    291 
    292    // It's up to the caller to know how to decode the response.
    293    // We just return the whole response.
    294    return restResponse;
    295  },
    296 
    297  /*
    298   * The prefix used for all notifications sent by this module.  This
    299   * allows the handler of notifications to be sure they are handling
    300   * notifications for the service they expect.
    301   *
    302   * If not set, no notifications will be sent.
    303   */
    304  observerPrefix: null,
    305 
    306  // Given an optional header value, notify that a backoff has been requested.
    307  _maybeNotifyBackoff(response, headerName) {
    308    if (!this.observerPrefix || !response.headers) {
    309      return;
    310    }
    311    let headerVal = response.headers[headerName];
    312    if (!headerVal) {
    313      return;
    314    }
    315    let backoffInterval;
    316    try {
    317      backoffInterval = parseInt(headerVal, 10);
    318    } catch (ex) {
    319      lazy.log.error(
    320        "hawkclient response had invalid backoff value in '" +
    321          headerName +
    322          "' header: " +
    323          headerVal
    324      );
    325      return;
    326    }
    327    Observers.notify(
    328      this.observerPrefix + ":backoff:interval",
    329      backoffInterval
    330    );
    331  },
    332 
    333  // override points for testing.
    334  newHAWKAuthenticatedRESTRequest(uri, credentials, extra) {
    335    return new HAWKAuthenticatedRESTRequest(uri, credentials, extra);
    336  },
    337 };