diff --git a/services/common/Makefile.in b/services/common/Makefile.in index 9599fa2fef1..df3d037e2b2 100644 --- a/services/common/Makefile.in +++ b/services/common/Makefile.in @@ -3,6 +3,7 @@ # You can obtain one at http://mozilla.org/MPL/2.0/. modules := \ + hawk.js \ storageservice.js \ stringbundle.js \ tokenserverclient.js \ diff --git a/services/common/hawk.js b/services/common/hawk.js new file mode 100644 index 00000000000..3843554e3e8 --- /dev/null +++ b/services/common/hawk.js @@ -0,0 +1,201 @@ +/* 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"; + +/* + * HAWK is an HTTP authentication scheme using a message authentication code + * (MAC) algorithm to provide partial HTTP request cryptographic verification. + * + * For details, see: https://github.com/hueniverse/hawk + * + * With HAWK, it is essential that the clocks on clients and server not have an + * absolute delta of greater than one minute, as the HAWK protocol uses + * timestamps to reduce the possibility of replay attacks. However, it is + * likely that some clients' clocks will be more than a little off, especially + * in mobile devices, which would break HAWK-based services (like sync and + * firefox accounts) for those clients. + * + * This library provides a stateful HAWK client that calculates (roughly) the + * clock delta on the client vs the server. The library provides an interface + * for deriving HAWK credentials and making HAWK-authenticated REST requests to + * a single remote server. Therefore, callers who want to interact with + * multiple HAWK services should instantiate one HawkClient per service. + */ + +this.EXPORTED_SYMBOLS = ["HawkClient"]; + +const {interfaces: Ci, utils: Cu} = Components; + +Cu.import("resource://gre/modules/FxAccountsCommon.js"); +Cu.import("resource://services-common/utils.js"); +Cu.import("resource://services-crypto/utils.js"); +Cu.import("resource://services-common/rest.js"); +Cu.import("resource://gre/modules/Promise.jsm"); + +/* + * A general purpose client for making HAWK authenticated requests to a single + * host. Keeps track of the clock offset between the client and the host for + * computation of the timestamp in the HAWK Authorization header. + * + * Clients should create one HawkClient object per each server they wish to + * interact with. + * + * @param host + * The url of the host + */ +function HawkClient(host) { + this.host = host; + + // Clock offset in milliseconds between our client's clock and the date + // reported in responses from our host. + this._localtimeOffsetMsec = 0; +} + +HawkClient.prototype = { + + /* + * Construct an error message for a response. Private. + * + * @param restResponse + * A RESTResponse object from a RESTRequest + * + * @param errorString + * A string describing the error + */ + _constructError: function(restResponse, errorString) { + return { + error: errorString, + message: restResponse.statusText, + code: restResponse.status, + errno: restResponse.status + }; + }, + + /* + * + * Update clock offset by determining difference from date gives in the (RFC + * 1123) Date header of a server response. Because HAWK tolerates a window + * of one minute of clock skew (so two minutes total since the skew can be + * positive or negative), the simple method of calculating offset here is + * probably good enough. We keep the value in milliseconds to make life + * easier, even though the value will not have millisecond accuracy. + * + * @param dateString + * An RFC 1123 date string (e.g., "Mon, 13 Jan 2014 21:45:06 GMT") + * + * For HAWK clock skew and replay protection, see + * https://github.com/hueniverse/hawk#replay-protection + */ + _updateClockOffset: function(dateString) { + try { + let serverDateMsec = Date.parse(dateString); + this._localtimeOffsetMsec = serverDateMsec - this.now(); + log.debug("Clock offset vs " + this.host + ": " + this._localtimeOffsetMsec); + } catch(err) { + log.warn("Bad date header in server response: " + dateString); + } + }, + + /* + * Get the current clock offset in milliseconds. + * + * The offset is the number of milliseconds that must be added to the client + * clock to make it equal to the server clock. For example, if the client is + * five minutes ahead of the server, the localtimeOffsetMsec will be -300000. + */ + get localtimeOffsetMsec() { + return this._localtimeOffsetMsec; + }, + + /* + * return current time in milliseconds + */ + now: function() { + return Date.now(); + }, + + /* A general method for sending raw RESTRequest calls authorized using HAWK + * + * @param path + * API endpoint path + * @param method + * The HTTP request method + * @param credentials + * Hawk credentials + * @param payloadObj + * An object that can be encodable as JSON as the payload of the + * request + * @return Promise + * Returns a promise that resolves to the text response of the API call, + * or is rejected with an error. If the server response can be parsed + * as JSON and contains an 'error' property, the promise will be + * rejected with this JSON-parsed response. + */ + request: function(path, method, credentials=null, payloadObj={}, retryOK=true) { + method = method.toLowerCase(); + + let deferred = Promise.defer(); + let uri = this.host + path; + let self = this; + + function onComplete(error) { + let restResponse = this.response; + let status = restResponse.status; + + log.debug("(Response) code: " + status + + " - Status text: " + restResponse.statusText, + " - Response text: " + restResponse.body); + + if (error) { + // When things really blow up, reconstruct an error object that follows + // the general format of the server on error responses. + return deferred.reject(self._constructError(restResponse, error)); + } + + self._updateClockOffset(restResponse.headers["date"]); + + if (status === 401 && retryOK) { + // Retry once if we were rejected due to a bad timestamp. + // Clock offset is adjusted already in the top of this function. + log.debug("Received 401 for " + path + ": retrying"); + return deferred.resolve( + self.request(path, method, credentials, payloadObj, false)); + } + + // If the server returned a json error message, use it in the rejection + // of the promise. + // + // In the case of a 401, in which we are probably being rejected for a + // bad timestamp, retry exactly once, during which time clock offset will + // be adjusted. + + let jsonResponse = {}; + try { + jsonResponse = JSON.parse(restResponse.body); + } catch(notJSON) {} + + let okResponse = (200 <= status && status < 300); + if (!okResponse || jsonResponse.error) { + if (jsonResponse.error) { + return deferred.reject(jsonResponse); + } + return deferred.reject(self._constructError(restResponse, "Request failed")); + } + // It's up to the caller to know how to decode the response. + // We just return the raw text. + deferred.resolve(this.response.body); + }; + + let extra = { + now: this.now(), + localtimeOffsetMsec: this.localtimeOffsetMsec, + }; + + let request = new HAWKAuthenticatedRESTRequest(uri, credentials, extra); + request[method](payloadObj, onComplete); + + return deferred.promise; + } +} diff --git a/services/common/rest.js b/services/common/rest.js index 1c043c270dc..f53bdfff503 100644 --- a/services/common/rest.js +++ b/services/common/rest.js @@ -9,7 +9,8 @@ const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; this.EXPORTED_SYMBOLS = [ "RESTRequest", "RESTResponse", - "TokenAuthenticatedRESTRequest" + "TokenAuthenticatedRESTRequest", + "HAWKAuthenticatedRESTRequest", ]; #endif @@ -146,6 +147,11 @@ RESTRequest.prototype = { COMPLETED: 4, ABORTED: 8, + /** + * HTTP status text of response + */ + statusText: null, + /** * Request timeout (in seconds, though decimal values can be used for * up to millisecond granularity.) @@ -612,8 +618,7 @@ RESTResponse.prototype = { get status() { let status; try { - let channel = this.request.channel.QueryInterface(Ci.nsIHttpChannel); - status = channel.responseStatus; + status = this.request.channel.responseStatus; } catch (ex) { this._log.debug("Caught exception fetching HTTP status code:" + CommonUtils.exceptionStr(ex)); @@ -623,14 +628,29 @@ RESTResponse.prototype = { return this.status = status; }, + /** + * HTTP status text + */ + get statusText() { + let statusText; + try { + statusText = this.request.channel.responseStatusText; + } catch (ex) { + this._log.debug("Caught exception fetching HTTP status text:" + + CommonUtils.exceptionStr(ex)); + return null; + } + delete this.statusText; + return this.statusText = statusText; + }, + /** * Boolean flag that indicates whether the HTTP status code is 2xx or not. */ get success() { let success; try { - let channel = this.request.channel.QueryInterface(Ci.nsIHttpChannel); - success = channel.requestSucceeded; + success = this.request.channel.requestSucceeded; } catch (ex) { this._log.debug("Caught exception fetching HTTP success flag:" + CommonUtils.exceptionStr(ex)); @@ -704,3 +724,60 @@ TokenAuthenticatedRESTRequest.prototype = { ); }, }; + +/** + * Single-use HAWK-authenticated HTTP requests to RESTish resources. + * + * @param uri + * (String) URI for the RESTRequest constructor + * + * @param credentials + * (Object) Optional credentials for computing HAWK authentication + * header. + * + * @param payloadObj + * (Object) Optional object to be converted to JSON payload + * + * @param extra + * (Object) Optional extra params for HAWK header computation. + * Valid properties are: + * + * now: , + * localtimeOffsetMsec: + * + * extra.localtimeOffsetMsec is the value in milliseconds that must be added to + * the local clock to make it agree with the server's clock. For instance, if + * the local clock is two minutes ahead of the server, the time offset in + * milliseconds will be -120000. + */ +this.HAWKAuthenticatedRESTRequest = + function HawkAuthenticatedRESTRequest(uri, credentials, extra={}) { + RESTRequest.call(this, uri); + + this.credentials = credentials; + this.now = extra.now || Date.now(); + this.localtimeOffsetMsec = extra.localtimeOffsetMsec || 0; + this._log.trace("local time, offset: " + this.now + ", " + (this.localtimeOffsetMsec)); +}; +HAWKAuthenticatedRESTRequest.prototype = { + __proto__: RESTRequest.prototype, + + dispatch: function dispatch(method, data, onComplete, onProgress) { + if (this.credentials) { + let options = { + now: this.now, + localtimeOffsetMsec: this.localtimeOffsetMsec, + credentials: this.credentials, + payload: data && JSON.stringify(data) || "", + contentType: "application/json; charset=utf-8", + }; + let header = CryptoUtils.computeHAWK(this.uri, method, options); + this.setHeader("Authorization", header.field); + this._log.trace("hawk auth header: " + header.field); + } + + return RESTRequest.prototype.dispatch.call( + this, method, data, onComplete, onProgress + ); + } +}; diff --git a/services/common/tests/unit/test_hawk.js b/services/common/tests/unit/test_hawk.js new file mode 100644 index 00000000000..eea7414bc34 --- /dev/null +++ b/services/common/tests/unit/test_hawk.js @@ -0,0 +1,485 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://services-common/hawk.js"); + +const SECOND_MS = 1000; +const MINUTE_MS = SECOND_MS * 60; +const HOUR_MS = MINUTE_MS * 60; + +const TEST_CREDS = { + id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x", + key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=", + algorithm: "sha256" +}; + +initTestLogging("Trace"); + +add_task(function test_now() { + let client = new HawkClient("https://example.com"); + + do_check_true(client.now() - Date.now() < SECOND_MS); + run_next_test(); +}); + +add_task(function test_updateClockOffset() { + let client = new HawkClient("https://example.com"); + + let now = new Date(); + let serverDate = now.toUTCString(); + + // Client's clock is off + client.now = () => { return now.valueOf() + HOUR_MS; } + + client._updateClockOffset(serverDate); + + // Check that they're close; there will likely be a one-second rounding + // error, so checking strict equality will likely fail. + // + // localtimeOffsetMsec is how many milliseconds to add to the local clock so + // that it agrees with the server. We are one hour ahead of the server, so + // our offset should be -1 hour. + do_check_true(Math.abs(client.localtimeOffsetMsec + HOUR_MS) <= SECOND_MS); + + run_next_test(); +}); + +add_task(function test_authenticated_get_request() { + let message = "{\"msg\": \"Great Success!\"}"; + let method = "GET"; + + let server = httpd_setup({"/foo": (request, response) => { + do_check_true(request.hasHeader("Authorization")); + + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(message, message.length); + } + }); + + let client = new HawkClient(server.baseURI); + + let response = yield client.request("/foo", method, TEST_CREDS); + let result = JSON.parse(response); + + do_check_eq("Great Success!", result.msg); + + yield deferredStop(server); +}); + +add_task(function test_authenticated_post_request() { + let method = "POST"; + + let server = httpd_setup({"/foo": (request, response) => { + do_check_true(request.hasHeader("Authorization")); + + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "application/json"); + response.bodyOutputStream.writeFrom(request.bodyInputStream, request.bodyInputStream.available()); + } + }); + + let client = new HawkClient(server.baseURI); + + let response = yield client.request("/foo", method, TEST_CREDS, {foo: "bar"}); + let result = JSON.parse(response); + + do_check_eq("bar", result.foo); + + yield deferredStop(server); +}); + +add_task(function test_credentials_optional() { + let method = "GET"; + let server = httpd_setup({ + "/foo": (request, response) => { + do_check_false(request.hasHeader("Authorization")); + + let message = JSON.stringify({msg: "you're in the friend zone"}); + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "application/json"); + response.bodyOutputStream.write(message, message.length); + } + }); + + let client = new HawkClient(server.baseURI); + let result = yield client.request("/foo", method); // credentials undefined + do_check_eq(JSON.parse(result).msg, "you're in the friend zone"); + yield deferredStop(server); +}); + +add_task(function test_server_error() { + let message = "Ohai!"; + let method = "GET"; + + let server = httpd_setup({"/foo": (request, response) => { + response.setStatusLine(request.httpVersion, 418, "I am a Teapot"); + response.bodyOutputStream.write(message, message.length); + } + }); + + let client = new HawkClient(server.baseURI); + + try { + yield client.request("/foo", method, TEST_CREDS); + } catch(err) { + do_check_eq(418, err.code); + do_check_eq("I am a Teapot", err.message); + } + + yield deferredStop(server); +}); + +add_task(function test_server_error_json() { + let message = JSON.stringify({error: "Cannot get ye flask."}); + let method = "GET"; + + let server = httpd_setup({"/foo": (request, response) => { + response.setStatusLine(request.httpVersion, 400, "What wouldst thou deau?"); + response.bodyOutputStream.write(message, message.length); + } + }); + + let client = new HawkClient(server.baseURI); + + try { + yield client.request("/foo", method, TEST_CREDS); + } catch(err) { + do_check_eq("Cannot get ye flask.", err.error); + } + + yield deferredStop(server); +}); + +add_task(function test_offset_after_request() { + let message = "Ohai!"; + let method = "GET"; + + let server = httpd_setup({"/foo": (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(message, message.length); + } + }); + + let client = new HawkClient(server.baseURI); + let now = Date.now(); + client.now = () => { return now + HOUR_MS; }; + + do_check_eq(client.localtimeOffsetMsec, 0); + + let response = yield client.request("/foo", method, TEST_CREDS); + // Should be about an hour off + do_check_true(Math.abs(client.localtimeOffsetMsec + HOUR_MS) < SECOND_MS); + + yield deferredStop(server); +}); + +add_task(function test_offset_in_hawk_header() { + let message = "Ohai!"; + let method = "GET"; + + let server = httpd_setup({ + "/first": function(request, response) { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(message, message.length); + }, + + "/second": function(request, response) { + // We see a better date now in the ts component of the header + let delta = getTimestampDelta(request.getHeader("Authorization")); + let message = "Delta: " + delta; + + // We're now within HAWK's one-minute window. + // I hope this isn't a recipe for intermittent oranges ... + if (delta < MINUTE_MS) { + response.setStatusLine(request.httpVersion, 200, "OK"); + } else { + response.setStatusLine(request.httpVersion, 400, "Delta: " + delta); + } + response.bodyOutputStream.write(message, message.length); + } + }); + + let client = new HawkClient(server.baseURI); + function getOffset() { + return client.localtimeOffsetMsec; + } + + client.now = () => { + return Date.now() + 12 * HOUR_MS; + }; + + // We begin with no offset + do_check_eq(client.localtimeOffsetMsec, 0); + yield client.request("/first", method, TEST_CREDS); + + // After the first server response, our offset is updated to -12 hours. + // We should be safely in the window, now. + do_check_true(Math.abs(client.localtimeOffsetMsec + 12 * HOUR_MS) < MINUTE_MS); + yield client.request("/second", method, TEST_CREDS); + + yield deferredStop(server); +}); + +add_task(function test_2xx_success() { + // Just to ensure that we're not biased toward 200 OK for success + let credentials = { + id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x", + key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=", + algorithm: "sha256" + }; + let method = "GET"; + + let server = httpd_setup({"/foo": (request, response) => { + response.setStatusLine(request.httpVersion, 202, "Accepted"); + } + }); + + let client = new HawkClient(server.baseURI); + + let response = yield client.request("/foo", method, credentials); + + // Shouldn't be any content in a 202 + do_check_eq(response, ""); + + yield deferredStop(server); + +}); + +add_task(function test_retry_request_on_fail() { + let attempts = 0; + let credentials = { + id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x", + key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=", + algorithm: "sha256" + }; + let method = "GET"; + + let server = httpd_setup({ + "/maybe": function(request, response) { + // This path should be hit exactly twice; once with a bad timestamp, and + // again when the client retries the request with a corrected timestamp. + attempts += 1; + do_check_true(attempts <= 2); + + let delta = getTimestampDelta(request.getHeader("Authorization")); + + // First time through, we should have a bad timestamp + if (attempts === 1) { + do_check_true(delta > MINUTE_MS); + let message = "never!!!"; + response.setStatusLine(request.httpVersion, 401, "Unauthorized"); + return response.bodyOutputStream.write(message, message.length); + } + + // Second time through, timestamp should be corrected by client + do_check_true(delta < MINUTE_MS); + let message = "i love you!!!"; + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(message, message.length); + } + }); + + let client = new HawkClient(server.baseURI); + function getOffset() { + return client.localtimeOffsetMsec; + } + + client.now = () => { + return Date.now() + 12 * HOUR_MS; + }; + + // We begin with no offset + do_check_eq(client.localtimeOffsetMsec, 0); + + // Request will have bad timestamp; client will retry once + let response = yield client.request("/maybe", method, credentials); + do_check_eq(response, "i love you!!!"); + + yield deferredStop(server); +}); + +add_task(function test_multiple_401_retry_once() { + // Like test_retry_request_on_fail, but always return a 401 + // and ensure that the client only retries once. + let attempts = 0; + let credentials = { + id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x", + key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=", + algorithm: "sha256" + }; + let method = "GET"; + + let server = httpd_setup({ + "/maybe": function(request, response) { + // This path should be hit exactly twice; once with a bad timestamp, and + // again when the client retries the request with a corrected timestamp. + attempts += 1; + + do_check_true(attempts <= 2); + + let message = "never!!!"; + response.setStatusLine(request.httpVersion, 401, "Unauthorized"); + response.bodyOutputStream.write(message, message.length); + } + }); + + let client = new HawkClient(server.baseURI); + function getOffset() { + return client.localtimeOffsetMsec; + } + + client.now = () => { + return Date.now() - 12 * HOUR_MS; + }; + + // We begin with no offset + do_check_eq(client.localtimeOffsetMsec, 0); + + // Request will have bad timestamp; client will retry once + try { + yield client.request("/maybe", method, credentials); + } catch (err) { + do_check_eq(err.code, 401); + } + do_check_eq(attempts, 2); + + yield deferredStop(server); +}); + +add_task(function test_500_no_retry() { + // If we get a 500 error, the client should not retry (as it would with a + // 401) + let attempts = 0; + let credentials = { + id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x", + key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=", + algorithm: "sha256" + }; + let method = "GET"; + + let server = httpd_setup({ + "/no-shutup": function() { + attempts += 1; + let message = "Cannot get ye flask."; + response.setStatusLine(request.httpVersion, 500, "Internal server error"); + response.bodyOutputStream.write(message, message.length); + } + }); + + let client = new HawkClient(server.baseURI); + function getOffset() { + return client.localtimeOffsetMsec; + } + + // Throw off the clock so the HawkClient would want to retry the request if + // it could + client.now = () => { + return Date.now() - 12 * HOUR_MS; + }; + + // Request will 500; no retries + try { + yield client.request("/no-shutup", method, credentials); + } catch(err) { + do_check_eq(err.code, 500); + } + do_check_eq(attempts, 1); + + yield deferredStop(server); + +}); + +add_task(function test_401_then_500() { + // Like test_multiple_401_retry_once, but return a 500 to the + // second request, ensuring that the promise is properly rejected + // in client.request. + let attempts = 0; + let credentials = { + id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x", + key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=", + algorithm: "sha256" + }; + let method = "GET"; + + let server = httpd_setup({ + "/maybe": function(request, response) { + // This path should be hit exactly twice; once with a bad timestamp, and + // again when the client retries the request with a corrected timestamp. + attempts += 1; + do_check_true(attempts <= 2); + + let delta = getTimestampDelta(request.getHeader("Authorization")); + + // First time through, we should have a bad timestamp + // Client will retry + if (attempts === 1) { + do_check_true(delta > MINUTE_MS); + let message = "never!!!"; + response.setStatusLine(request.httpVersion, 401, "Unauthorized"); + return response.bodyOutputStream.write(message, message.length); + } + + // Second time through, timestamp should be corrected by client + // And fail on the client + do_check_true(delta < MINUTE_MS); + let message = "Cannot get ye flask."; + response.setStatusLine(request.httpVersion, 500, "Internal server error"); + response.bodyOutputStream.write(message, message.length); + } + }); + + let client = new HawkClient(server.baseURI); + function getOffset() { + return client.localtimeOffsetMsec; + } + + client.now = () => { + return Date.now() - 12 * HOUR_MS; + }; + + // We begin with no offset + do_check_eq(client.localtimeOffsetMsec, 0); + + // Request will have bad timestamp; client will retry once + try { + yield client.request("/maybe", method, credentials); + } catch(err) { + do_check_eq(err.code, 500); + } + do_check_eq(attempts, 2); + + yield deferredStop(server); +}); + +add_task(function throw_if_not_json_body() { + do_test_pending(); + let client = new HawkClient("https://example.com"); + try { + yield client.request("/bogus", "GET", {}, "I am not json"); + } catch(err) { + do_check_true(!!err.message); + do_test_finished(); + } +}); + +// End of tests. +// Utility functions follow + +function getTimestampDelta(authHeader, now=Date.now()) { + let tsMS = new Date( + parseInt(/ts="(\d+)"/.exec(authHeader)[1], 10) * SECOND_MS); + return Math.abs(tsMS - now); +} + +function deferredStop(server) { + let deferred = Promise.defer(); + server.stop(deferred.resolve); + return deferred.promise; +} + +function run_test() { + initTestLogging("Trace"); + run_next_test(); +} + diff --git a/services/common/tests/unit/test_restrequest.js b/services/common/tests/unit/test_restrequest.js index ec3e101fee0..0ad6a6a93f0 100644 --- a/services/common/tests/unit/test_restrequest.js +++ b/services/common/tests/unit/test_restrequest.js @@ -831,3 +831,62 @@ add_test(function test_not_sending_cookie() { server.stop(run_next_test); }); }); + +add_test(function test_hawk_authenticated_request() { + do_test_pending(); + + let onProgressCalled = false; + let postData = {your: "data"}; + + // An arbitrary date - Feb 2, 1971. It ends in a bunch of zeroes to make our + // computation with the hawk timestamp easier, since hawk throws away the + // millisecond values. + let then = 34329600000; + + let clockSkew = 120000; + let timeOffset = -1 * clockSkew; + let localTime = then + clockSkew; + + let credentials = { + id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x", + key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=", + algorithm: "sha256" + }; + + let server = httpd_setup({ + "/elysium": function(request, response) { + do_check_true(request.hasHeader("Authorization")); + + // check that the header timestamp is our arbitrary system date, not + // today's date. Note that hawk header timestamps are in seconds, not + // milliseconds. + let authorization = request.getHeader("Authorization"); + let tsMS = parseInt(/ts="(\d+)"/.exec(authorization)[1], 10) * 1000; + do_check_eq(tsMS, then); + + let message = "yay"; + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(message, message.length); + } + }); + + function onProgress() { + onProgressCalled = true; + } + + function onComplete(error) { + do_check_eq(200, this.response.status); + do_check_eq(this.response.body, "yay"); + do_check_true(onProgressCalled); + do_test_finished(); + server.stop(run_next_test); + } + + let url = server.baseURI + "/elysium"; + let extra = { + now: localTime, + localtimeOffsetMsec: timeOffset + }; + let request = new HAWKAuthenticatedRESTRequest(url, credentials, extra); + request.post(postData, onComplete, onProgress); +}); diff --git a/services/common/tests/unit/xpcshell.ini b/services/common/tests/unit/xpcshell.ini index 7c794269132..eb3ccc333e7 100644 --- a/services/common/tests/unit/xpcshell.ini +++ b/services/common/tests/unit/xpcshell.ini @@ -25,6 +25,7 @@ firefox-appdir = browser [test_async_querySpinningly.js] [test_bagheera_server.js] [test_bagheera_client.js] +[test_hawk.js] [test_observers.js] [test_restrequest.js] [test_tokenauthenticatedrequest.js] diff --git a/services/fxaccounts/FxAccounts.jsm b/services/fxaccounts/FxAccounts.jsm index 4787f99f9df..8f26a8a6e2e 100644 --- a/services/fxaccounts/FxAccounts.jsm +++ b/services/fxaccounts/FxAccounts.jsm @@ -67,6 +67,26 @@ InternalMethods = function(mock) { } InternalMethods.prototype = { + /** + * Return the current time in milliseconds as an integer. Allows tests to + * manipulate the date to simulate certificate expiration. + */ + now: function() { + return this.fxAccountsClient.now(); + }, + + /** + * Return clock offset in milliseconds, as reported by the fxAccountsClient. + * This can be overridden for testing. + * + * The offset is the number of milliseconds that must be added to the client + * clock to make it equal to the server clock. For example, if the client is + * five minutes ahead of the server, the localtimeOffsetMsec will be -300000. + */ + get localtimeOffsetMsec() { + return this.fxAccountsClient.localtimeOffsetMsec; + }, + /** * Ask the server whether the user's email has been verified */ @@ -206,9 +226,13 @@ InternalMethods.prototype = { log.debug("getAssertionFromCert"); let payload = {}; let d = Promise.defer(); + let options = { + localtimeOffsetMsec: internal.localtimeOffsetMsec, + now: internal.now() + }; // "audience" should look like "http://123done.org". // The generated assertion will expire in two minutes. - jwcrypto.generateAssertion(cert, keyPair, audience, function(err, signed) { + jwcrypto.generateAssertion(cert, keyPair, audience, options, (err, signed) => { if (err) { log.error("getAssertionFromCert: " + err); d.reject(err); @@ -228,7 +252,7 @@ InternalMethods.prototype = { return Promise.resolve(this.cert.cert); } // else get our cert signed - let willBeValidUntil = this.now() + CERT_LIFETIME; + let willBeValidUntil = internal.now() + CERT_LIFETIME; return this.getCertificateSigned(data.sessionToken, keyPair.serializedPublicKey, CERT_LIFETIME) @@ -255,7 +279,7 @@ InternalMethods.prototype = { return Promise.resolve(this.keyPair.keyPair); } // Otherwse, create a keypair and set validity limit. - let willBeValidUntil = this.now() + KEY_LIFETIME; + let willBeValidUntil = internal.now() + KEY_LIFETIME; let d = Promise.defer(); jwcrypto.generateKeyPair("DS160", (err, kp) => { if (err) { diff --git a/services/fxaccounts/FxAccountsClient.jsm b/services/fxaccounts/FxAccountsClient.jsm index 8b5ba64b603..9dc6a2827f9 100644 --- a/services/fxaccounts/FxAccountsClient.jsm +++ b/services/fxaccounts/FxAccountsClient.jsm @@ -6,9 +6,11 @@ this.EXPORTED_SYMBOLS = ["FxAccountsClient"]; const {classes: Cc, interfaces: Ci, utils: Cu} = Components; +Cu.import("resource://gre/modules/Log.jsm"); Cu.import("resource://gre/modules/Promise.jsm"); Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://services-common/utils.js"); +Cu.import("resource://services-common/hawk.js"); Cu.import("resource://services-crypto/utils.js"); Cu.import("resource://gre/modules/FxAccountsCommon.js"); @@ -19,11 +21,17 @@ try { } catch(keepDefault) {} const HOST = _host; -const PREFIX_NAME = "identity.mozilla.com/picl/v1/"; - -const XMLHttpRequest = - Components.Constructor("@mozilla.org/xmlextras/xmlhttprequest;1"); +const PROTOCOL_VERSION = "identity.mozilla.com/picl/v1/"; +function KW(context) { + // This is used as a salt. It's specified by the protocol. Note that the + // value of PROTOCOL_VERSION does not refer in any wy to the version of the + // Firefox Accounts API. For this reason, it is not exposed as a pref. + // + // See: + // https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol#creating-the-account + return PROTOCOL_VERSION + context; +} function stringToHex(str) { let encoder = new TextEncoder("utf-8"); @@ -43,9 +51,37 @@ function bytesToHex(bytes) { this.FxAccountsClient = function(host = HOST) { this.host = host; + + // The FxA auth server expects requests to certain endpoints to be authorized + // using Hawk. + this.hawk = new HawkClient(host); }; this.FxAccountsClient.prototype = { + + /** + * Return client clock offset, in milliseconds, as determined by hawk client. + * Provided because callers should not have to know about hawk + * implementation. + * + * The offset is the number of milliseconds that must be added to the client + * clock to make it equal to the server clock. For example, if the client is + * five minutes ahead of the server, the localtimeOffsetMsec will be -300000. + */ + get localtimeOffsetMsec() { + return this.hawk.localtimeOffsetMsec; + }, + + /* + * Return current time in milliseconds + * + * Not used by this module, but made available to the FxAccounts.jsm + * that uses this client. + */ + now: function() { + return this.hawk.now(); + }, + /** * Create a new Firefox Account and authenticate * @@ -149,7 +185,7 @@ this.FxAccountsClient.prototype = { let creds = this._deriveHawkCredentials(keyFetchTokenHex, "keyFetchToken"); let keyRequestKey = creds.extra.slice(0, 32); let morecreds = CryptoUtils.hkdf(keyRequestKey, undefined, - PREFIX_NAME + "account/keys", 3 * 32); + KW("account/keys"), 3 * 32); let respHMACKey = morecreds.slice(0, 32); let respXORKey = morecreds.slice(32, 96); @@ -199,8 +235,10 @@ this.FxAccountsClient.prototype = { return Promise.resolve() .then(_ => this._request("/certificate/sign", "POST", creds, body)) .then(resp => resp.cert, - err => {dump("HAWK.signCertificate error: " + JSON.stringify(err) + "\n"); - throw err;}); + err => { + log.error("HAWK.signCertificate error: " + JSON.stringify(err)); + throw err; + }); }, /** @@ -219,8 +257,10 @@ this.FxAccountsClient.prototype = { // the account exists (result) => true, (err) => { + log.error("accountExists: error: " + JSON.stringify(err)); // the account doesn't exist if (err.errno === 102) { + log.debug("returning false for errno 102"); return false; } // propogate other request errors @@ -251,7 +291,7 @@ this.FxAccountsClient.prototype = { */ _deriveHawkCredentials: function (tokenHex, context, size) { let token = CommonUtils.hexToBytes(tokenHex); - let out = CryptoUtils.hkdf(token, undefined, PREFIX_NAME + context, size || 3 * 32); + let out = CryptoUtils.hkdf(token, undefined, KW(context), size || 3 * 32); return { algorithm: "sha256", @@ -286,63 +326,21 @@ this.FxAccountsClient.prototype = { */ _request: function hawkRequest(path, method, credentials, jsonPayload) { let deferred = Promise.defer(); - let xhr = new XMLHttpRequest({mozSystem: true}); - let URI = this.host + path; - let payload; - xhr.mozBackgroundRequest = true; - - if (jsonPayload) { - payload = JSON.stringify(jsonPayload); - } - - log.debug("(HAWK request) - Path: " + path + " - Method: " + method + - " - Payload: " + payload); - - xhr.open(method, URI); - xhr.channel.loadFlags = Ci.nsIChannel.LOAD_BYPASS_CACHE | - Ci.nsIChannel.INHIBIT_CACHING; - - // When things really blow up, reconstruct an error object that follows the general format - // of the server on error responses. - function constructError(err) { - return { error: err, message: xhr.statusText, code: xhr.status, errno: xhr.status }; - } - - xhr.onerror = function() { - deferred.reject(constructError('Request failed')); - }; - - xhr.onload = function onload() { - try { - let response = JSON.parse(xhr.responseText); - log.debug("(Response) Code: " + xhr.status + " - Status text: " + - xhr.statusText + " - Response text: " + xhr.responseText); - if (xhr.status !== 200 || response.error) { - // In this case, the response is an object with error information. - return deferred.reject(response); + this.hawk.request(path, method, credentials, jsonPayload).then( + (responseText) => { + try { + let response = JSON.parse(responseText); + deferred.resolve(response); + } catch (err) { + deferred.reject({error: err}); } - deferred.resolve(response); - } catch (e) { - log.error("(Response) Code: " + xhr.status + " - Status text: " + - xhr.statusText); - deferred.reject(constructError(e)); + }, + + (error) => { + deferred.reject(error); } - }; - - let uri = Services.io.newURI(URI, null, null); - - if (credentials) { - let header = CryptoUtils.computeHAWK(uri, method, { - credentials: credentials, - payload: payload, - contentType: "application/json" - }); - xhr.setRequestHeader("authorization", header.field); - } - - xhr.setRequestHeader("Content-Type", "application/json"); - xhr.send(payload); + ); return deferred.promise; }, diff --git a/services/fxaccounts/tests/xpcshell/test_accounts.js b/services/fxaccounts/tests/xpcshell/test_accounts.js index a379b26e302..5a3f09544f0 100644 --- a/services/fxaccounts/tests/xpcshell/test_accounts.js +++ b/services/fxaccounts/tests/xpcshell/test_accounts.js @@ -11,6 +11,12 @@ Cu.import("resource://gre/modules/FxAccountsCommon.js"); Cu.import("resource://gre/modules/Promise.jsm"); Cu.import("resource://gre/modules/Log.jsm"); +const ONE_HOUR_MS = 1000 * 60 * 60; +const ONE_DAY_MS = ONE_HOUR_MS * 24; +const TWO_MINUTES_MS = 1000 * 60 * 2; + +initTestLogging("Trace"); + // XXX until bug 937114 is fixed Cu.importGlobalProperties(['atob']); @@ -383,56 +389,83 @@ add_task(function test_getAssertion() { yield fxa.setSignedInUser(creds); _("== ready to go\n"); - let now = 138000000*1000; - let start = Date.now(); + // Start with a nice arbitrary but realistic date. Here we use a nice RFC + // 1123 date string like we would get from an HTTP header. Over the course of + // the test, we will update 'now', but leave 'start' where it is. + let now = Date.parse("Mon, 13 Jan 2014 21:45:06 GMT"); + let start = now; fxa._now_is = now; + let d = fxa.getAssertion("audience.example.com"); // At this point, a thread has been spawned to generate the keys. _("-- back from fxa.getAssertion\n"); fxa._d_signCertificate.resolve("cert1"); let assertion = yield d; - let finish = Date.now(); do_check_eq(fxa._getCertificateSigned_calls.length, 1); do_check_eq(fxa._getCertificateSigned_calls[0][0], "sessionToken"); do_check_neq(assertion, null); - _("ASSERTION: "+assertion+"\n"); + _("ASSERTION: " + assertion + "\n"); let pieces = assertion.split("~"); do_check_eq(pieces[0], "cert1"); do_check_neq(fxa.internal.keyPair, undefined); - _(fxa.internal.keyPair.validUntil+"\n"); + _(fxa.internal.keyPair.validUntil + "\n"); let p2 = pieces[1].split("."); let header = JSON.parse(atob(p2[0])); - _("HEADER: "+JSON.stringify(header)+"\n"); + _("HEADER: " + JSON.stringify(header) + "\n"); do_check_eq(header.alg, "DS128"); let payload = JSON.parse(atob(p2[1])); - _("PAYLOAD: "+JSON.stringify(payload)+"\n"); + _("PAYLOAD: " + JSON.stringify(payload) + "\n"); do_check_eq(payload.aud, "audience.example.com"); - // FxAccounts KEY_LIFETIME - do_check_eq(fxa.internal.keyPair.validUntil, now + (12*3600*1000)); - // FxAccounts CERT_LIFETIME - do_check_eq(fxa.internal.cert.validUntil, now + (6*3600*1000)); - _("delta: "+(new Date(payload.exp) - now)+"\n"); + do_check_eq(fxa.internal.keyPair.validUntil, start + KEY_LIFETIME); + do_check_eq(fxa.internal.cert.validUntil, start + CERT_LIFETIME); + _("delta: " + Date.parse(payload.exp - start) + "\n"); let exp = Number(payload.exp); - // jwcrypto.jsm uses an unmocked Date.now()+2min to decide on the - // expiration time, so we test that it's inside a specific timebox - do_check_true(start + 2*60*1000 <= exp); - do_check_true(exp <= finish + 2*60*1000); + + do_check_eq(exp, now + TWO_MINUTES_MS); // Reset for next call. fxa._d_signCertificate = Promise.defer(); - // Getting a new assertion "soon" (i.e. w/o incrementing "now"), even for + // Getting a new assertion "soon" (i.e., w/o incrementing "now"), even for // a new audience, should not provoke key generation or a signing request. assertion = yield fxa.getAssertion("other.example.com"); + + // There were no additional calls - same number of getcert calls as before do_check_eq(fxa._getCertificateSigned_calls.length, 1); - // But "waiting" (i.e. incrementing "now") will need a new key+signature. - fxa._now_is = now + 24*3600*1000; - start = Date.now(); - d = fxa.getAssertion("third.example.com"); + // Wait an hour; assertion expires, but not the certificate + now += ONE_HOUR_MS; + fxa._now_is = now; + + // This won't block on anything - will make an assertion, but not get a + // new certificate. + assertion = yield fxa.getAssertion("third.example.com"); + + // Test will time out if that failed (i.e., if that had to go get a new cert) + pieces = assertion.split("~"); + do_check_eq(pieces[0], "cert1"); + p2 = pieces[1].split("."); + header = JSON.parse(atob(p2[0])); + payload = JSON.parse(atob(p2[1])); + do_check_eq(payload.aud, "third.example.com"); + + // The keypair and cert should have the same validity as before, but the + // expiration time of the assertion should be different. We compare this to + // the initial start time, to which they are relative, not the current value + // of "now". + + do_check_eq(fxa.internal.keyPair.validUntil, start + KEY_LIFETIME); + do_check_eq(fxa.internal.cert.validUntil, start + CERT_LIFETIME); + exp = Number(payload.exp); + do_check_eq(exp, now + TWO_MINUTES_MS); + + // Now we wait even longer, and expect both assertion and cert to expire. So + // we will have to get a new keypair and cert. + now += ONE_DAY_MS; + fxa._now_is = now; + d = fxa.getAssertion("fourth.example.com"); fxa._d_signCertificate.resolve("cert2"); assertion = yield d; - finish = Date.now(); do_check_eq(fxa._getCertificateSigned_calls.length, 2); do_check_eq(fxa._getCertificateSigned_calls[1][0], "sessionToken"); pieces = assertion.split("~"); @@ -440,15 +473,12 @@ add_task(function test_getAssertion() { p2 = pieces[1].split("."); header = JSON.parse(atob(p2[0])); payload = JSON.parse(atob(p2[1])); - do_check_eq(payload.aud, "third.example.com"); - // 12*3600*1000 === FxAccounts KEY_LIFETIME - do_check_eq(fxa.internal.keyPair.validUntil, now + 24*3600*1000 + (12*3600*1000)); - // 6*3600*1000 === FxAccounts CERT_LIFETIME - do_check_eq(fxa.internal.cert.validUntil, now + 24*3600*1000 + (6*3600*1000)); + do_check_eq(payload.aud, "fourth.example.com"); + do_check_eq(fxa.internal.keyPair.validUntil, now + KEY_LIFETIME); + do_check_eq(fxa.internal.cert.validUntil, now + CERT_LIFETIME); exp = Number(payload.exp); - do_check_true(start + 2*60*1000 <= exp); - do_check_true(exp <= finish + 2*60*1000); + do_check_eq(exp, now + TWO_MINUTES_MS); _("----- DONE ----\n"); }); diff --git a/services/fxaccounts/tests/xpcshell/test_client.js b/services/fxaccounts/tests/xpcshell/test_client.js index ff38c39795a..751ee761922 100644 --- a/services/fxaccounts/tests/xpcshell/test_client.js +++ b/services/fxaccounts/tests/xpcshell/test_client.js @@ -5,14 +5,16 @@ Cu.import("resource://gre/modules/FxAccountsClient.jsm"); Cu.import("resource://gre/modules/Promise.jsm"); Cu.import("resource://services-common/utils.js"); +const FAKE_SESSION_TOKEN = "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf"; + function run_test() { run_next_test(); } function deferredStop(server) { - let deferred = Promise.defer(); - server.stop(deferred.resolve); - return deferred.promise; + let deferred = Promise.defer(); + server.stop(deferred.resolve); + return deferred.promise; } add_test(function test_hawk_credentials() { @@ -28,7 +30,6 @@ add_test(function test_hawk_credentials() { }); add_task(function test_authenticated_get_request() { - let message = "{\"msg\": \"Great Success!\"}"; let credentials = { id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x", @@ -79,7 +80,6 @@ add_task(function test_authenticated_post_request() { }); add_task(function test_500_error() { - let message = "

Ooops!

"; let method = "GET"; @@ -181,7 +181,7 @@ add_task(function test_api_endpoints() { result = yield client.signIn('mé@example.com', 'bigsecret'); do_check_eq("NotARealToken", result.sessionToken); - result = yield client.signOut('NotARealToken'); + result = yield client.signOut(FAKE_SESSION_TOKEN); do_check_eq(typeof result, "object"); result = yield client.recoveryEmailStatus('NotARealToken'); diff --git a/services/sync/modules/browserid_identity.js b/services/sync/modules/browserid_identity.js index 0bfd9b776cb..ee79364cccc 100644 --- a/services/sync/modules/browserid_identity.js +++ b/services/sync/modules/browserid_identity.js @@ -20,6 +20,7 @@ Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://services-sync/constants.js"); Cu.import("resource://gre/modules/Promise.jsm"); Cu.import("resource://services-sync/stages/cluster.js"); +Cu.import("resource://gre/modules/FxAccounts.jsm"); // Lazy imports to prevent unnecessary load on startup. XPCOMUtils.defineLazyModuleGetter(this, "BulkKeyBundle", @@ -149,7 +150,7 @@ this.BrowserIDManager.prototype = { }); // and we are done - the fetch continues on in the background... }).then(null, err => { - dump("err in processing logged in account "+err.message); + this._log.error("Processing logged in account: " + err.message); }); }, @@ -193,7 +194,11 @@ this.BrowserIDManager.prototype = { * Provide override point for testing token expiration. */ _now: function() { - return Date.now(); + return this._fxaService.internal.now() + }, + + get _localtimeOffsetMsec() { + return this._fxaService.internal.localtimeOffsetMsec; }, get account() { @@ -464,8 +469,16 @@ this.BrowserIDManager.prototype = { key: this._token.key, }; method = method || httpObject.method; - let headerValue = CryptoUtils.computeHAWK(httpObject.uri, method, - {credentials: credentials}); + + // Get the local clock offset from the Firefox Accounts server. This should + // be close to the offset from the storage server. + let options = { + now: this._now(), + localtimeOffsetMsec: this._localtimeOffsetMsec, + credentials: credentials, + }; + + let headerValue = CryptoUtils.computeHAWK(httpObject.uri, method, options); return {headers: {authorization: headerValue.field}}; }, diff --git a/services/sync/tests/unit/test_browserid_identity.js b/services/sync/tests/unit/test_browserid_identity.js index 2eed354ad16..acecf385204 100644 --- a/services/sync/tests/unit/test_browserid_identity.js +++ b/services/sync/tests/unit/test_browserid_identity.js @@ -8,11 +8,48 @@ Cu.import("resource://services-sync/util.js"); Cu.import("resource://services-common/utils.js"); Cu.import("resource://services-crypto/utils.js"); Cu.import("resource://testing-common/services/sync/utils.js"); +Cu.import("resource://services-common/hawk.js"); +Cu.import("resource://gre/modules/FxAccounts.jsm"); +Cu.import("resource://gre/modules/FxAccountsClient.jsm"); + +const SECOND_MS = 1000; +const MINUTE_MS = SECOND_MS * 60; +const HOUR_MS = MINUTE_MS * 60; let identityConfig = makeIdentityConfig(); let browseridManager = new BrowserIDManager(); configureFxAccountIdentity(browseridManager, identityConfig); +/** + * Mock client clock and skew vs server in FxAccounts signed-in user module and + * API client. browserid_identity.js queries these values to construct HAWK + * headers. We will use this to test clock skew compensation in these headers + * below. + */ +let MockFxAccountsClient = function() { + FxAccountsClient.apply(this); +}; +MockFxAccountsClient.prototype = { + __proto__: FxAccountsClient.prototype +}; + +let MockFxAccounts = function() { + this._now_is = Date.now(); + + let mockInternal = { + now: () => { + return this._now_is; + }, + + fxAccountsClient: new MockFxAccountsClient() + }; + + FxAccounts.apply(this, [mockInternal]); +}; +MockFxAccounts.prototype = { + __proto__: FxAccounts.prototype, +}; + function run_test() { initTestLogging("Trace"); Log.repository.getLogger("Sync.Identity").level = Log.Level.Trace; @@ -60,6 +97,111 @@ add_test(function test_getRESTRequestAuthenticator() { } ); +add_test(function test_resourceAuthenticatorSkew() { + _("BrowserIDManager Resource Authenticator compensates for clock skew in Hawk header."); + + // Clock is skewed 12 hours into the future + // We pick a date in the past so we don't risk concealing bugs in code that + // uses new Date() instead of our given date. + let now = new Date("Fri Apr 09 2004 00:00:00 GMT-0700").valueOf() + 12 * HOUR_MS; + let browseridManager = new BrowserIDManager(); + let hawkClient = new HawkClient("https://example.net/v1", "/foo"); + + // mock fxa hawk client skew + hawkClient.now = function() { + dump("mocked client now: " + now + '\n'); + return now; + } + // Imagine there's already been one fxa request and the hawk client has + // already detected skew vs the fxa auth server. + let localtimeOffsetMsec = -1 * 12 * HOUR_MS; + hawkClient._localtimeOffsetMsec = localtimeOffsetMsec; + + let fxaClient = new MockFxAccountsClient(); + fxaClient.hawk = hawkClient; + + // Sanity check + do_check_eq(hawkClient.now(), now); + do_check_eq(hawkClient.localtimeOffsetMsec, localtimeOffsetMsec); + + // Properly picked up by the client + do_check_eq(fxaClient.now(), now); + do_check_eq(fxaClient.localtimeOffsetMsec, localtimeOffsetMsec); + + let fxa = new MockFxAccounts(); + fxa._now_is = now; + fxa.internal.fxAccountsClient = fxaClient; + + // Picked up by the signed-in user module + do_check_eq(fxa.internal.now(), now); + do_check_eq(fxa.internal.localtimeOffsetMsec, localtimeOffsetMsec); + + // Mocks within mocks... + configureFxAccountIdentity(browseridManager, identityConfig); + browseridManager._fxaService = fxa; + do_check_eq(browseridManager._fxaService.internal.now(), now); + do_check_eq(browseridManager._fxaService.internal.localtimeOffsetMsec, + localtimeOffsetMsec); + + let request = new SyncStorageRequest("https://example.net/i/like/pie/"); + let authenticator = browseridManager.getResourceAuthenticator(); + let output = authenticator(request, 'GET'); + dump("output" + JSON.stringify(output)); + let authHeader = output.headers.authorization; + do_check_true(authHeader.startsWith('Hawk')); + + // Skew correction is applied in the header and we're within the two-minute + // window. + do_check_eq(getTimestamp(authHeader), now - 12 * HOUR_MS); + do_check_true( + (getTimestampDelta(authHeader, now) - 12 * HOUR_MS) < 2 * MINUTE_MS); + + run_next_test(); +}); + +add_test(function test_RESTResourceAuthenticatorSkew() { + _("BrowserIDManager REST Resource Authenticator compensates for clock skew in Hawk header."); + + // Clock is skewed 12 hours into the future from our arbitary date + let now = new Date("Fri Apr 09 2004 00:00:00 GMT-0700").valueOf() + 12 * HOUR_MS; + let browseridManager = new BrowserIDManager(); + let hawkClient = new HawkClient("https://example.net/v1", "/foo"); + + // mock fxa hawk client skew + hawkClient.now = function() { + return now; + } + // Imagine there's already been one fxa request and the hawk client has + // already detected skew vs the fxa auth server. + hawkClient._localtimeOffsetMsec = -1 * 12 * HOUR_MS; + + let fxaClient = new MockFxAccountsClient(); + fxaClient.hawk = hawkClient; + let fxa = new MockFxAccounts(); + fxa._now_is = now; + fxa.internal.fxAccountsClient = fxaClient; + + configureFxAccountIdentity(browseridManager, identityConfig); + browseridManager._fxaService = fxa; + + do_check_eq(browseridManager._fxaService.internal.now(), now); + + let request = new SyncStorageRequest("https://example.net/i/like/pie/"); + let authenticator = browseridManager.getResourceAuthenticator(); + let output = authenticator(request, 'GET'); + dump("output" + JSON.stringify(output)); + let authHeader = output.headers.authorization; + do_check_true(authHeader.startsWith('Hawk')); + + // Skew correction is applied in the header and we're within the two-minute + // window. + do_check_eq(getTimestamp(authHeader), now - 12 * HOUR_MS); + do_check_true( + (getTimestampDelta(authHeader, now) - 12 * HOUR_MS) < 2 * MINUTE_MS); + + run_next_test(); +}); + add_test(function test_tokenExpiration() { _("BrowserIDManager notices token expiration:"); let bimExp = new BrowserIDManager(); @@ -143,3 +285,14 @@ add_test(function test_computeXClientStateHeader() { run_next_test(); }); +// End of tests +// Utility functions follow + +function getTimestamp(hawkAuthHeader) { + return parseInt(/ts="(\d+)"/.exec(hawkAuthHeader)[1], 10) * SECOND_MS; +} + +function getTimestampDelta(hawkAuthHeader, now=Date.now()) { + return Math.abs(getTimestamp(hawkAuthHeader) - now); +} + diff --git a/toolkit/identity/jwcrypto.jsm b/toolkit/identity/jwcrypto.jsm index e20bc1672db..b30e30ec216 100644 --- a/toolkit/identity/jwcrypto.jsm +++ b/toolkit/identity/jwcrypto.jsm @@ -24,6 +24,7 @@ XPCOMUtils.defineLazyServiceGetter(this, this.EXPORTED_SYMBOLS = ["jwcrypto"]; const ALGORITHMS = { RS256: "RS256", DS160: "DS160" }; +const DURATION_MS = 1000 * 60 * 2; // 2 minutes default assertion lifetime function log(...aMessageArgs) { Logger.log.apply(Logger, ["jwcrypto"].concat(aMessageArgs)); @@ -87,6 +88,23 @@ function jwcryptoClass() } jwcryptoClass.prototype = { + /* + * Determine the expiration of the assertion. Returns expiry date + * in milliseconds as integer. + * + * @param localtimeOffsetMsec (optional) + * The number of milliseconds that must be added to the local clock + * for it to agree with the server. For example, if the local clock + * if two minutes fast, localtimeOffsetMsec would be -120000 + * + * @param now (options) + * Current date in milliseconds. Useful for mocking clock + * skew in testing. + */ + getExpiration: function(duration=DURATION_MS, localtimeOffsetMsec=0, now=Date.now()) { + return now + localtimeOffsetMsec + duration; + }, + isCertValid: function(aCert, aCallback) { // XXX check expiration, bug 769850 aCallback(true); @@ -97,7 +115,41 @@ jwcryptoClass.prototype = { generateKeyPair(aAlgorithmName, aCallback); }, - generateAssertion: function(aCert, aKeyPair, aAudience, aCallback) { + /* + * Generate an assertion and return it through the provided callback. + * + * @param aCert + * Identity certificate + * + * @param aKeyPair + * KeyPair object + * + * @param aAudience + * Audience of the assertion + * + * @param aOptions (optional) + * Can include: + * { + * localtimeOffsetMsec: , + * now: + * duration: + * } + * + * localtimeOffsetMsec is the number of milliseconds that need to be + * added to the local clock time to make it concur with the server. + * For example, if the local clock is two minutes fast, the offset in + * milliseconds would be -120000. + * + * @param aCallback + * Function to invoke with resulting assertion. Assertion + * will be string or null on failure. + */ + generateAssertion: function(aCert, aKeyPair, aAudience, aOptions, aCallback) { + if (typeof aOptions == "function") { + aCallback = aOptions; + aOptions = { }; + } + // for now, we hack the algorithm name // XXX bug 769851 var header = {"alg": "DS128"}; @@ -105,9 +157,8 @@ jwcryptoClass.prototype = { JSON.stringify(header)); var payload = { - // expires in 2 minutes - // XXX clock skew needs exploration bug 769852 - exp: Date.now() + (2 * 60 * 1000), + exp: this.getExpiration( + aOptions.duration, aOptions.localtimeOffsetMsec, aOptions.now), aud: aAudience }; var payloadBytes = IdentityCryptoService.base64UrlEncode( diff --git a/toolkit/identity/tests/unit/test_jwcrypto.js b/toolkit/identity/tests/unit/test_jwcrypto.js index 309ef266808..b60c7a5d0fc 100644 --- a/toolkit/identity/tests/unit/test_jwcrypto.js +++ b/toolkit/identity/tests/unit/test_jwcrypto.js @@ -12,9 +12,18 @@ XPCOMUtils.defineLazyModuleGetter(this, "IDService", XPCOMUtils.defineLazyModuleGetter(this, "jwcrypto", "resource://gre/modules/identity/jwcrypto.jsm"); +XPCOMUtils.defineLazyServiceGetter(this, + "CryptoService", + "@mozilla.org/identity/crypto-service;1", + "nsIIdentityCryptoService"); + const RP_ORIGIN = "http://123done.org"; const INTERNAL_ORIGIN = "browserid://"; +const SECOND_MS = 1000; +const MINUTE_MS = SECOND_MS * 60; +const HOUR_MS = MINUTE_MS * 60; + function test_sanity() { do_test_pending(); @@ -43,11 +52,11 @@ function test_get_assertion() { jwcrypto.generateKeyPair( "DS160", function(err, kp) { - jwcrypto.generateAssertion("fake-cert", kp, RP_ORIGIN, function(err, assertion) { + jwcrypto.generateAssertion("fake-cert", kp, RP_ORIGIN, (err, backedAssertion) => { do_check_null(err); - // more checks on assertion - log("assertion", assertion); + do_check_eq(backedAssertion.split("~").length, 2); + do_check_eq(backedAssertion.split(".").length, 3); do_test_finished(); run_next_test(); @@ -116,7 +125,128 @@ function test_dsa() { jwcrypto.generateKeyPair("DS160", checkDSA); } -var TESTS = [test_sanity, test_generate, test_get_assertion]; +function test_get_assertion_with_offset() { + do_test_pending(); + + + // Use an arbitrary date in the past to ensure we don't accidentally pass + // this test with current dates, missing offsets, etc. + let serverMsec = Date.parse("Tue Oct 31 2000 00:00:00 GMT-0800"); + + // local clock skew + // clock is 12 hours fast; -12 hours offset must be applied + let localtimeOffsetMsec = -1 * 12 * HOUR_MS; + let localMsec = serverMsec - localtimeOffsetMsec; + + jwcrypto.generateKeyPair( + "DS160", + function(err, kp) { + jwcrypto.generateAssertion("fake-cert", kp, RP_ORIGIN, + { duration: MINUTE_MS, + localtimeOffsetMsec: localtimeOffsetMsec, + now: localMsec}, + function(err, backedAssertion) { + do_check_null(err); + + // properly formed + let cert; + let assertion; + [cert, assertion] = backedAssertion.split("~"); + + do_check_eq(cert, "fake-cert"); + do_check_eq(assertion.split(".").length, 3); + + let components = extractComponents(assertion); + + // Expiry is within two minutes, corrected for skew + let exp = parseInt(components.payload.exp, 10); + do_check_true(exp - serverMsec === MINUTE_MS); + + do_test_finished(); + run_next_test(); + } + ); + } + ); +} + +function test_assertion_lifetime() { + do_test_pending(); + + jwcrypto.generateKeyPair( + "DS160", + function(err, kp) { + jwcrypto.generateAssertion("fake-cert", kp, RP_ORIGIN, + {duration: MINUTE_MS}, + function(err, backedAssertion) { + do_check_null(err); + + // properly formed + let cert; + let assertion; + [cert, assertion] = backedAssertion.split("~"); + + do_check_eq(cert, "fake-cert"); + do_check_eq(assertion.split(".").length, 3); + + let components = extractComponents(assertion); + + // Expiry is within one minute, as we specified above + let exp = parseInt(components.payload.exp, 10); + do_check_true(Math.abs(Date.now() - exp) > 50 * SECOND_MS); + do_check_true(Math.abs(Date.now() - exp) <= MINUTE_MS); + + do_test_finished(); + run_next_test(); + } + ); + } + ); +} + +// End of tests +// Helper function follow + +function extractComponents(signedObject) { + if (typeof(signedObject) != 'string') { + throw new Error("malformed signature " + typeof(signedObject)); + } + + let parts = signedObject.split("."); + if (parts.length != 3) { + throw new Error("signed object must have three parts, this one has " + parts.length); + } + + let headerSegment = parts[0]; + let payloadSegment = parts[1]; + let cryptoSegment = parts[2]; + + let header = JSON.parse(CryptoService.base64UrlDecode(headerSegment)); + let payload = JSON.parse(CryptoService.base64UrlDecode(payloadSegment)); + + // Ensure well-formed header + do_check_eq(Object.keys(header).length, 1); + do_check_true(!!header.alg); + + // Ensure well-formed payload + for (let field of ["exp", "aud"]) { + do_check_true(!!payload[field]); + } + + return {header: header, + payload: payload, + headerSegment: headerSegment, + payloadSegment: payloadSegment, + cryptoSegment: cryptoSegment}; +}; + +let TESTS = [ + test_sanity, + test_generate, + test_get_assertion, + test_get_assertion_with_offset, + test_assertion_lifetime, +]; TESTS = TESTS.concat([test_rsa, test_dsa]);