Bug 957863 - Use FxA auth clock skew in hawk, jwcrypto, and sync. r=warner, r=rnewman

This commit is contained in:
Jed Parsons 2014-01-23 18:04:38 -08:00
parent 80b665fc66
commit 556c2f110c
14 changed files with 1340 additions and 117 deletions

View File

@ -3,6 +3,7 @@
# You can obtain one at http://mozilla.org/MPL/2.0/.
modules := \
hawk.js \
storageservice.js \
stringbundle.js \
tokenserverclient.js \

201
services/common/hawk.js Normal file
View File

@ -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;
}
}

View File

@ -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: <current time in milliseconds>,
* localtimeOffsetMsec: <local clock offset vs server>
*
* 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
);
}
};

View File

@ -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();
}

View File

@ -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);
});

View File

@ -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]

View File

@ -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) {

View File

@ -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() {
this.hawk.request(path, method, credentials, jsonPayload).then(
(responseText) => {
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);
}
let response = JSON.parse(responseText);
deferred.resolve(response);
} catch (e) {
log.error("(Response) Code: " + xhr.status + " - Status text: " +
xhr.statusText);
deferred.reject(constructError(e));
} catch (err) {
deferred.reject({error: err});
}
};
},
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);
(error) => {
deferred.reject(error);
}
xhr.setRequestHeader("Content-Type", "application/json");
xhr.send(payload);
);
return deferred.promise;
},

View File

@ -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,15 +389,18 @@ 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);
@ -407,32 +416,56 @@ add_task(function test_getAssertion() {
let payload = JSON.parse(atob(p2[1]));
_("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");
});

View File

@ -5,6 +5,8 @@ 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();
}
@ -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 = "<h1>Ooops!</h1>";
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');

View File

@ -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}};
},

View File

@ -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);
}

View File

@ -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: <clock offset in milliseconds>,
* now: <current date in milliseconds>
* duration: <validity duration for this assertion in milliseconds>
* }
*
* 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(

View File

@ -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]);