Bug 943521 - Use onepw prototcol in fxa client. r=ckarlof

This commit is contained in:
Jed Parsons 2014-02-04 22:14:30 -08:00
parent 866677a688
commit 3199967ef7
11 changed files with 930 additions and 179 deletions

View File

@ -5,6 +5,24 @@
Cu.import("resource://services-common/utils.js");
// A wise line of Greek verse, and the utf-8 byte encoding.
// N.b., Greek begins at utf-8 ce 91
const TEST_STR = "πόλλ' οἶδ' ἀλώπηξ, ἀλλ' ἐχῖνος ἓν μέγα";
const TEST_HEX = h("cf 80 cf 8c ce bb ce bb 27 20 ce bf e1 bc b6 ce"+
"b4 27 20 e1 bc 80 ce bb cf 8e cf 80 ce b7 ce be"+
"2c 20 e1 bc 80 ce bb ce bb 27 20 e1 bc 90 cf 87"+
"e1 bf 96 ce bd ce bf cf 82 20 e1 bc 93 ce bd 20"+
"ce bc ce ad ce b3 ce b1");
// Integer byte values for the above
const TEST_BYTES = [207,128,207,140,206,187,206,187,
39, 32,206,191,225,188,182,206,
180, 39, 32,225,188,128,206,187,
207,142,207,128,206,183,206,190,
44, 32,225,188,128,206,187,206,
187, 39, 32,225,188,144,207,135,
225,191,150,206,189,206,191,207,
130, 32,225,188,147,206,189, 32,
206,188,206,173,206,179,206,177];
function run_test() {
run_next_test();
@ -53,3 +71,70 @@ add_test(function test_bad_argument() {
run_next_test();
});
add_task(function test_stringAsHex() {
do_check_eq(TEST_HEX, CommonUtils.stringAsHex(TEST_STR));
run_next_test();
});
add_task(function test_hexAsString() {
do_check_eq(TEST_STR, CommonUtils.hexAsString(TEST_HEX));
run_next_test();
});
add_task(function test_hexToBytes() {
let bytes = CommonUtils.hexToBytes(TEST_HEX);
do_check_eq(TEST_BYTES.length, bytes.length);
// Ensure that the decimal values of each byte are correct
do_check_true(arraysEqual(TEST_BYTES,
CommonUtils.stringToByteArray(bytes)));
run_next_test();
});
add_task(function test_bytesToHex() {
// Create a list of our character bytes from the reference int values
let bytes = CommonUtils.byteArrayToString(TEST_BYTES);
do_check_eq(TEST_HEX, CommonUtils.bytesAsHex(bytes));
run_next_test();
});
add_task(function test_stringToBytes() {
do_check_true(arraysEqual(TEST_BYTES,
CommonUtils.stringToByteArray(CommonUtils.stringToBytes(TEST_STR))));
run_next_test();
});
add_task(function test_stringRoundTrip() {
do_check_eq(TEST_STR,
CommonUtils.hexAsString(CommonUtils.stringAsHex(TEST_STR)));
run_next_test();
});
add_task(function test_hexRoundTrip() {
do_check_eq(TEST_HEX,
CommonUtils.stringAsHex(CommonUtils.hexAsString(TEST_HEX)));
run_next_test();
});
add_task(function test_byteArrayRoundTrip() {
do_check_true(arraysEqual(TEST_BYTES,
CommonUtils.stringToByteArray(CommonUtils.byteArrayToString(TEST_BYTES))));
run_next_test();
});
// turn formatted test vectors into normal hex strings
function h(hexStr) {
return hexStr.replace(/\s+/g, "");
}
function arraysEqual(a1, a2) {
if (a1.length !== a2.length) {
return false;
}
for (let i = 0; i < a1.length; i++) {
if (a1[i] !== a2[i]) {
return false;
}
}
return true;
}

View File

@ -196,12 +196,21 @@ this.CommonUtils = {
return [String.fromCharCode(byte) for each (byte in bytes)].join("");
},
stringToByteArray: function stringToByteArray(bytesString) {
return [String.charCodeAt(byte) for each (byte in bytesString)];
},
bytesAsHex: function bytesAsHex(bytes) {
let hex = "";
for (let i = 0; i < bytes.length; i++) {
hex += ("0" + bytes[i].charCodeAt().toString(16)).slice(-2);
}
return hex;
return [("0" + bytes.charCodeAt(byte).toString(16)).slice(-2)
for (byte in bytes)].join("");
},
stringAsHex: function stringAsHex(str) {
return CommonUtils.bytesAsHex(CommonUtils.encodeUTF8(str));
},
stringToBytes: function stringToBytes(str) {
return CommonUtils.hexToBytes(CommonUtils.stringAsHex(str));
},
hexToBytes: function hexToBytes(str) {
@ -212,6 +221,10 @@ this.CommonUtils = {
return String.fromCharCode.apply(String, bytes);
},
hexAsString: function hexAsString(hex) {
return CommonUtils.decodeUTF8(CommonUtils.hexToBytes(hex));
},
/**
* Base32 encode (RFC 4648) a string
*/

View File

@ -167,20 +167,24 @@ this.CryptoUtils = {
* c: the number of iterations, a positive integer: e.g., 4096
* dkLen: the length in octets of the destination
* key, a positive integer: e.g., 16
* hmacAlg: The algorithm to use for hmac
* hmacLen: The hmac length
*
* The default value of 20 for hmacLen is appropriate for SHA1. For SHA256,
* hmacLen should be 32.
*
* The output is an octet string of length dkLen, which you
* can encode as you wish.
*/
pbkdf2Generate : function pbkdf2Generate(P, S, c, dkLen) {
pbkdf2Generate : function pbkdf2Generate(P, S, c, dkLen,
hmacAlg=Ci.nsICryptoHMAC.SHA1, hmacLen=20) {
// We don't have a default in the algo itself, as NSS does.
// Use the constant.
if (!dkLen) {
dkLen = SYNC_KEY_DECODED_LENGTH;
}
/* For HMAC-SHA-1 */
const HLEN = 20;
function F(S, c, i, h) {
function XOR(a, b, isA) {
@ -216,27 +220,27 @@ this.CryptoUtils = {
}
ret = U[0];
for (j = 1; j < c; j++) {
for (let j = 1; j < c; j++) {
ret = CommonUtils.byteArrayToString(XOR(ret, U[j]));
}
return ret;
}
let l = Math.ceil(dkLen / HLEN);
let r = dkLen - ((l - 1) * HLEN);
let l = Math.ceil(dkLen / hmacLen);
let r = dkLen - ((l - 1) * hmacLen);
// Reuse the key and the hasher. Remaking them 4096 times is 'spensive.
let h = CryptoUtils.makeHMACHasher(Ci.nsICryptoHMAC.SHA1,
let h = CryptoUtils.makeHMACHasher(hmacAlg,
CryptoUtils.makeHMACKey(P));
T = [];
let T = [];
for (let i = 0; i < l;) {
T[i] = F(S, c, ++i, h);
}
let ret = "";
for (i = 0; i < l-1;) {
for (let i = 0; i < l-1;) {
ret += T[i++];
}
ret += T[l - 1].substr(0, r);

View File

@ -1,15 +1,166 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
// Evil.
let btoa = Cu.import("resource://services-common/utils.js").btoa;
// XXX until bug 937114 is fixed
Cu.importGlobalProperties(['btoa']);
Cu.import("resource://services-crypto/utils.js");
Cu.import("resource://services-common/utils.js");
let {bytesAsHex: b2h} = CommonUtils;
function run_test() {
run_next_test();
}
add_task(function test_pbkdf2() {
let symmKey16 = CryptoUtils.pbkdf2Generate("secret phrase", "DNXPzPpiwn", 4096, 16);
do_check_eq(symmKey16.length, 16);
do_check_eq(btoa(symmKey16), "d2zG0d2cBfXnRwMUGyMwyg==");
do_check_eq(CommonUtils.encodeBase32(symmKey16), "O5WMNUO5TQC7LZ2HAMKBWIZQZI======");
let symmKey32 = CryptoUtils.pbkdf2Generate("passphrase", "salt", 4096, 32);
do_check_eq(symmKey32.length, 32);
});
// http://tools.ietf.org/html/rfc6070
// PBKDF2 HMAC-SHA1 Test Vectors
add_task(function test_pbkdf2_hmac_sha1() {
let pbkdf2 = CryptoUtils.pbkdf2Generate;
let vectors = [
{P: "password", // (8 octets)
S: "salt", // (4 octets)
c: 1,
dkLen: 20,
DK: h("0c 60 c8 0f 96 1f 0e 71"+
"f3 a9 b5 24 af 60 12 06"+
"2f e0 37 a6"), // (20 octets)
},
{P: "password", // (8 octets)
S: "salt", // (4 octets)
c: 2,
dkLen: 20,
DK: h("ea 6c 01 4d c7 2d 6f 8c"+
"cd 1e d9 2a ce 1d 41 f0"+
"d8 de 89 57"), // (20 octets)
},
{P: "password", // (8 octets)
S: "salt", // (4 octets)
c: 4096,
dkLen: 20,
DK: h("4b 00 79 01 b7 65 48 9a"+
"be ad 49 d9 26 f7 21 d0"+
"65 a4 29 c1"), // (20 octets)
},
// XXX Uncomment the following test after Bug 968567 lands
//
// XXX As it stands, I estimate that the CryptoUtils implementation will
// take approximately 16 hours in my 2.3GHz MacBook to perform this many
// rounds.
//
// {P: "password", // (8 octets)
// S: "salt" // (4 octets)
// c: 16777216,
// dkLen = 20,
// DK: h("ee fe 3d 61 cd 4d a4 e4"+
// "e9 94 5b 3d 6b a2 15 8c"+
// "26 34 e9 84"), // (20 octets)
// },
{P: "passwordPASSWORDpassword", // (24 octets)
S: "saltSALTsaltSALTsaltSALTsaltSALTsalt", // (36 octets)
c: 4096,
dkLen: 25,
DK: h("3d 2e ec 4f e4 1c 84 9b"+
"80 c8 d8 36 62 c0 e4 4a"+
"8b 29 1a 96 4c f2 f0 70"+
"38"), // (25 octets)
},
{P: "pass\0word", // (9 octets)
S: "sa\0lt", // (5 octets)
c: 4096,
dkLen: 16,
DK: h("56 fa 6a a7 55 48 09 9d"+
"cc 37 d7 f0 34 25 e0 c3"), // (16 octets)
},
];
for (let v of vectors) {
do_check_eq(v.DK, b2h(pbkdf2(v.P, v.S, v.c, v.dkLen)));
}
run_next_test();
});
// I can't find any normative ietf test vectors for pbkdf2 hmac-sha256.
// The following vectors are derived with the same inputs as above (the sha1
// test). Results verified by users here:
// https://stackoverflow.com/questions/5130513/pbkdf2-hmac-sha2-test-vectors
add_task(function test_pbkdf2_hmac_sha256() {
let pbkdf2 = CryptoUtils.pbkdf2Generate;
let vectors = [
{P: "password", // (8 octets)
S: "salt", // (4 octets)
c: 1,
dkLen: 32,
DK: h("12 0f b6 cf fc f8 b3 2c"+
"43 e7 22 52 56 c4 f8 37"+
"a8 65 48 c9 2c cc 35 48"+
"08 05 98 7c b7 0b e1 7b"), // (32 octets)
},
{P: "password", // (8 octets)
S: "salt", // (4 octets)
c: 2,
dkLen: 32,
DK: h("ae 4d 0c 95 af 6b 46 d3"+
"2d 0a df f9 28 f0 6d d0"+
"2a 30 3f 8e f3 c2 51 df"+
"d6 e2 d8 5a 95 47 4c 43"), // (32 octets)
},
{P: "password", // (8 octets)
S: "salt", // (4 octets)
c: 4096,
dkLen: 32,
DK: h("c5 e4 78 d5 92 88 c8 41"+
"aa 53 0d b6 84 5c 4c 8d"+
"96 28 93 a0 01 ce 4e 11"+
"a4 96 38 73 aa 98 13 4a"), // (32 octets)
},
{P: "passwordPASSWORDpassword", // (24 octets)
S: "saltSALTsaltSALTsaltSALTsaltSALTsalt", // (36 octets)
c: 4096,
dkLen: 40,
DK: h("34 8c 89 db cb d3 2b 2f"+
"32 d8 14 b8 11 6e 84 cf"+
"2b 17 34 7e bc 18 00 18"+
"1c 4e 2a 1f b8 dd 53 e1"+
"c6 35 51 8c 7d ac 47 e9"), // (40 octets)
},
{P: "pass\0word", // (9 octets)
S: "sa\0lt", // (5 octets)
c: 4096,
dkLen: 16,
DK: h("89 b6 9d 05 16 f8 29 89"+
"3c 69 62 26 65 0a 86 87"), // (16 octets)
},
];
for (let v of vectors) {
do_check_eq(v.DK,
b2h(pbkdf2(v.P, v.S, v.c, v.dkLen, Ci.nsICryptoHMAC.SHA256, 32)));
}
run_next_test();
});
// turn formatted test vectors into normal hex strings
function h(hexStr) {
return hexStr.replace(/\s+/g, "");
}

View File

@ -0,0 +1,139 @@
/* 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/. */
/**
* This module implements client-side key stretching for use in Firefox
* Accounts account creation and login.
*
* See https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol
*/
"use strict";
this.EXPORTED_SYMBOLS = ["Credentials"];
const {utils: Cu, interfaces: Ci} = Components;
Cu.import("resource://gre/modules/Log.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/Promise.jsm");
Cu.import("resource://services-crypto/utils.js");
Cu.import("resource://services-common/utils.js");
const PROTOCOL_VERSION = "identity.mozilla.com/picl/v1/";
const PBKDF2_ROUNDS = 1000;
const STRETCHED_PW_LENGTH_BYTES = 32;
const HKDF_SALT = CommonUtils.hexToBytes("00");
const HKDF_LENGTH = 32;
const HMAC_ALGORITHM = Ci.nsICryptoHMAC.SHA256;
const HMAC_LENGTH = 32;
// loglevel preference should be one of: "FATAL", "ERROR", "WARN", "INFO",
// "CONFIG", "DEBUG", "TRACE" or "ALL". We will be logging error messages by
// default.
const PREF_LOG_LEVEL = "identity.fxaccounts.loglevel";
try {
this.LOG_LEVEL =
Services.prefs.getPrefType(PREF_LOG_LEVEL) == Ci.nsIPrefBranch.PREF_STRING
&& Services.prefs.getCharPref(PREF_LOG_LEVEL);
} catch (e) {
this.LOG_LEVEL = Log.Level.Error;
}
let log = Log.repository.getLogger("Identity.FxAccounts");
log.level = LOG_LEVEL;
log.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter()));
this.Credentials = Object.freeze({
/**
* Make constants accessible to tests
*/
constants: {
PROTOCOL_VERSION: PROTOCOL_VERSION,
PBKDF2_ROUNDS: PBKDF2_ROUNDS,
STRETCHED_PW_LENGTH_BYTES: STRETCHED_PW_LENGTH_BYTES,
HKDF_SALT: HKDF_SALT,
HKDF_LENGTH: HKDF_LENGTH,
HMAC_ALGORITHM: HMAC_ALGORITHM,
HMAC_LENGTH: HMAC_LENGTH,
},
/**
* KW function from https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol
*
* keyWord derivation for use as a salt.
*
*
* @param {String} context String for use in generating salt
*
* @return {bitArray} the salt
*
* Note that PROTOCOL_VERSION does not refer in any way to the version of the
* Firefox Accounts API.
*/
keyWord: function(context) {
return CommonUtils.stringToBytes(PROTOCOL_VERSION + context);
},
/**
* KWE function from https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol
*
* keyWord extended with a name and an email.
*
* @param {String} name The name of the salt
* @param {String} email The email of the user.
*
* @return {bitArray} the salt combination with the namespace
*
* Note that PROTOCOL_VERSION does not refer in any way to the version of the
* Firefox Accounts API.
*/
keyWordExtended: function(name, email) {
return CommonUtils.stringToBytes(PROTOCOL_VERSION + name + ':' + email);
},
setup: function(emailInput, passwordInput, options={}) {
let deferred = Promise.defer();
log.debug("setup credentials for " + emailInput);
let hkdfSalt = options.hkdfSalt || HKDF_SALT;
let hkdfLength = options.hkdfLength || HKDF_LENGTH;
let hmacLength = options.hmacLength || HMAC_LENGTH;
let hmacAlgorithm = options.hmacAlgorithm || HMAC_ALGORITHM;
let stretchedPWLength = options.stretchedPassLength || STRETCHED_PW_LENGTH_BYTES;
let pbkdf2Rounds = options.pbkdf2Rounds || PBKDF2_ROUNDS;
let result = {
emailUTF8: emailInput,
passwordUTF8: passwordInput,
};
let password = CommonUtils.encodeUTF8(passwordInput);
let salt = this.keyWordExtended("quickStretch", emailInput);
let runnable = () => {
let start = Date.now();
let quickStretchedPW = CryptoUtils.pbkdf2Generate(
password, salt, pbkdf2Rounds, stretchedPWLength, hmacAlgorithm, hmacLength);
result.quickStretchedPW = quickStretchedPW;
result.authPW =
CryptoUtils.hkdf(quickStretchedPW, hkdfSalt, this.keyWord("authPW"), hkdfLength);
result.unwrapBKey =
CryptoUtils.hkdf(quickStretchedPW, hkdfSalt, this.keyWord("unwrapBkey"), hkdfLength);
log.debug("Credentials set up after " + (Date.now() - start) + " ms");
deferred.resolve(result);
}
Services.tm.currentThread.dispatch(runnable,
Ci.nsIThread.DISPATCH_NORMAL);
log.debug("Dispatched thread for credentials setup crypto work");
return deferred.promise;
}
});

View File

@ -13,6 +13,7 @@ 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");
Cu.import("resource://gre/modules/Credentials.jsm");
// Default can be changed by the preference 'identity.fxaccounts.auth.uri'
let _host = "https://api-accounts.dev.lcip.org/v1";
@ -21,34 +22,6 @@ try {
} catch(keepDefault) {}
const HOST = _host;
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");
let bytes = encoder.encode(str);
return bytesToHex(bytes);
}
// XXX Sadly, CommonUtils.bytesAsHex doesn't handle typed arrays.
function bytesToHex(bytes) {
let hex = [];
for (let i = 0; i < bytes.length; i++) {
hex.push((bytes[i] >>> 4).toString(16));
hex.push((bytes[i] & 0xF).toString(16));
}
return hex.join("");
}
this.FxAccountsClient = function(host = HOST) {
this.host = host;
@ -92,23 +65,18 @@ this.FxAccountsClient.prototype = {
* @return Promise
* Returns a promise that resolves to an object:
* {
* uid: the user's unique ID
* sessionToken: a session token
* uid: the user's unique ID (hex)
* sessionToken: a session token (hex)
* keyFetchToken: a key fetch token (hex)
* }
*/
signUp: function (email, password) {
let uid;
let hexEmail = stringToHex(email);
let uidPromise = this._request("/raw_password/account/create", "POST", null,
{email: hexEmail, password: password});
return uidPromise.then((result) => {
uid = result.uid;
return this.signIn(email, password)
.then(function(result) {
result.uid = uid;
return result;
});
signUp: function(email, password) {
return Credentials.setup(email, password).then((creds) => {
let data = {
email: creds.emailUTF8,
authPW: CommonUtils.bytesAsHex(creds.authPW),
};
return this._request("/account/create", "POST", null, data);
});
},
@ -122,15 +90,20 @@ this.FxAccountsClient.prototype = {
* @return Promise
* Returns a promise that resolves to an object:
* {
* uid: the user's unique ID
* sessionToken: a session token
* uid: the user's unique ID (hex)
* sessionToken: a session token (hex)
* keyFetchToken: a key fetch token (hex)
* verified: flag indicating verification status of the email
* }
*/
signIn: function signIn(email, password) {
let hexEmail = stringToHex(email);
return this._request("/raw_password/session/create", "POST", null,
{email: hexEmail, password: password});
return Credentials.setup(email, password).then((creds) => {
let data = {
email: creds.emailUTF8,
authPW: CommonUtils.bytesAsHex(creds.authPW),
};
return this._request("/account/login", "POST", null, data);
});
},
/**
@ -177,15 +150,16 @@ this.FxAccountsClient.prototype = {
* @return Promise
* Returns a promise that resolves to an object:
* {
* kA: an encryption key for recevorable data
* wrapKB: an encryption key that requires knowledge of the user's password
* kA: an encryption key for recevorable data (bytes)
* wrapKB: an encryption key that requires knowledge of the
* user's password (bytes)
* }
*/
accountKeys: function (keyFetchTokenHex) {
let creds = this._deriveHawkCredentials(keyFetchTokenHex, "keyFetchToken");
let keyRequestKey = creds.extra.slice(0, 32);
let morecreds = CryptoUtils.hkdf(keyRequestKey, undefined,
KW("account/keys"), 3 * 32);
Credentials.keyWord("account/keys"), 3 * 32);
let respHMACKey = morecreds.slice(0, 32);
let respXORKey = morecreds.slice(32, 96);
@ -251,22 +225,25 @@ this.FxAccountsClient.prototype = {
* if it doesn't. The promise is rejected on other errors.
*/
accountExists: function (email) {
let hexEmail = stringToHex(email);
return this._request("/auth/start", "POST", null, { email: hexEmail })
.then(
// 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 this.signIn(email, "").then(
(cantHappen) => {
throw new Error("How did I sign in with an empty password?");
},
(expectedError) => {
switch (expectedError.errno) {
case ERRNO_ACCOUNT_DOES_NOT_EXIST:
return false;
}
// propogate other request errors
throw err;
break;
case ERRNO_INCORRECT_PASSWORD:
return true;
break;
default:
// not so expected, any more ...
throw expectedError;
break;
}
);
}
);
},
/**
@ -291,7 +268,7 @@ this.FxAccountsClient.prototype = {
*/
_deriveHawkCredentials: function (tokenHex, context, size) {
let token = CommonUtils.hexToBytes(tokenHex);
let out = CryptoUtils.hkdf(token, undefined, KW(context), size || 3 * 32);
let out = CryptoUtils.hkdf(token, undefined, Credentials.keyWord(context), size || 3 * 32);
return {
algorithm: "sha256",
@ -333,11 +310,13 @@ this.FxAccountsClient.prototype = {
let response = JSON.parse(responseText);
deferred.resolve(response);
} catch (err) {
log.error("json parse error on response: " + responseText);
deferred.reject({error: err});
}
},
(error) => {
log.error("request error: " + JSON.stringify(error));
deferred.reject(error);
}
);

View File

@ -49,7 +49,7 @@ this.ONLOGOUT_NOTIFICATION = "fxaccounts:onlogout";
// Server errno.
// From https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#response-format
this.ERRNO_ACCOUNT_ALREADY_EXISTS = 101;
this.ERRNO_ACCOUNT_DOES_NOT_EXISTS = 102;
this.ERRNO_ACCOUNT_DOES_NOT_EXIST = 102;
this.ERRNO_INCORRECT_PASSWORD = 103;
this.ERRNO_UNVERIFIED_ACCOUNT = 104;
this.ERRNO_INVALID_VERIFICATION_CODE = 105;
@ -68,7 +68,7 @@ this.ERRNO_UNKNOWN_ERROR = 999;
// Errors.
this.ERROR_ACCOUNT_ALREADY_EXISTS = "ACCOUNT_ALREADY_EXISTS";
this.ERROR_ACCOUNT_DOES_NOT_EXISTS = "ACCOUNT_DOES_NOT_EXISTS";
this.ERROR_ACCOUNT_DOES_NOT_EXIST = "ACCOUNT_DOES_NOT_EXIST";
this.ERROR_ALREADY_SIGNED_IN_USER = "ALREADY_SIGNED_IN_USER";
this.ERROR_INVALID_ACCOUNTID = "INVALID_ACCOUNTID";
this.ERROR_INVALID_AUDIENCE = "INVALID_AUDIENCE";
@ -96,7 +96,7 @@ this.ERROR_UNVERIFIED_ACCOUNT = "UNVERIFIED_ACCOUNT";
// Error matching.
this.SERVER_ERRNO_TO_ERROR = {};
SERVER_ERRNO_TO_ERROR[ERRNO_ACCOUNT_ALREADY_EXISTS] = ERROR_ACCOUNT_ALREADY_EXISTS;
SERVER_ERRNO_TO_ERROR[ERRNO_ACCOUNT_DOES_NOT_EXISTS] = ERROR_ACCOUNT_DOES_NOT_EXISTS;
SERVER_ERRNO_TO_ERROR[ERRNO_ACCOUNT_DOES_NOT_EXIST] = ERROR_ACCOUNT_DOES_NOT_EXIST;
SERVER_ERRNO_TO_ERROR[ERRNO_INCORRECT_PASSWORD] = ERROR_INVALID_PASSWORD;
SERVER_ERRNO_TO_ERROR[ERRNO_UNVERIFIED_ACCOUNT] = ERROR_UNVERIFIED_ACCOUNT;
SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_VERIFICATION_CODE] = ERROR_INVALID_VERIFICATION_CODE;

View File

@ -9,6 +9,7 @@ PARALLEL_DIRS += ['interfaces']
TEST_DIRS += ['tests']
EXTRA_JS_MODULES += [
'Credentials.jsm',
'FxAccounts.jsm',
'FxAccountsClient.jsm',
'FxAccountsCommon.js',

View File

@ -4,6 +4,7 @@
Cu.import("resource://gre/modules/FxAccountsClient.jsm");
Cu.import("resource://gre/modules/Promise.jsm");
Cu.import("resource://services-common/utils.js");
Cu.import("resource://services-crypto/utils.js");
const FAKE_SESSION_TOKEN = "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf";
@ -101,131 +102,388 @@ add_task(function test_500_error() {
yield deferredStop(server);
});
add_task(function test_api_endpoints() {
let sessionMessage = JSON.stringify({sessionToken: "NotARealToken"});
let creationMessage = JSON.stringify({uid: "NotARealUid"});
let signoutMessage = JSON.stringify({});
let certSignMessage = JSON.stringify({cert: {bar: "baz"}});
let emailStatus = JSON.stringify({verified: true});
add_task(function test_signUp() {
let creationMessage = JSON.stringify({
uid: "uid",
sessionToken: "sessionToken",
keyFetchToken: "keyFetchToken"
});
let errorMessage = JSON.stringify({code: 400, errno: 101, error: "account exists"});
let created = false;
let authStarts = 0;
let server = httpd_setup({
"/account/create": function(request, response) {
let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
let jsonBody = JSON.parse(body);
function writeResp(response, msg) {
response.bodyOutputStream.write(msg, msg.length);
// https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol#wiki-test-vectors
do_check_eq(jsonBody.email, "andré@example.org");
if (!created) {
do_check_eq(jsonBody.authPW, "247b675ffb4c46310bc87e26d712153abe5e1c90ef00a4784594f97ef54f2375");
created = true;
response.setStatusLine(request.httpVersion, 200, "OK");
return response.bodyOutputStream.write(creationMessage, creationMessage.length);
}
// Error trying to create same account a second time
response.setStatusLine(request.httpVersion, 400, "Bad request");
return response.bodyOutputStream.write(errorMessage, errorMessage.length);
},
});
let client = new FxAccountsClient(server.baseURI);
let result = yield client.signUp('andré@example.org', 'pässwörd');
do_check_eq("uid", result.uid);
do_check_eq("sessionToken", result.sessionToken);
do_check_eq("keyFetchToken", result.keyFetchToken);
// Try to create account again. Triggers error path.
try {
result = yield client.signUp('andré@example.org', 'pässwörd');
} catch(expectedError) {
do_check_eq(101, expectedError.errno);
}
let server = httpd_setup(
{
"/raw_password/account/create": function(request, response) {
let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
let jsonBody = JSON.parse(body);
do_check_eq(jsonBody.email, "796f75406578616d706c652e636f6d");
do_check_eq(jsonBody.password, "biggersecret");
yield deferredStop(server);
});
response.setStatusLine(request.httpVersion, 200, "OK");
response.bodyOutputStream.write(creationMessage, creationMessage.length);
},
"/raw_password/session/create": function(request, response) {
let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
let jsonBody = JSON.parse(body);
if (jsonBody.password === "bigsecret") {
do_check_eq(jsonBody.email, "6dc3a9406578616d706c652e636f6d");
} else if (jsonBody.password === "biggersecret") {
do_check_eq(jsonBody.email, "796f75406578616d706c652e636f6d");
}
add_task(function test_signIn() {
let sessionMessage = JSON.stringify({sessionToken: FAKE_SESSION_TOKEN});
let errorMessage = JSON.stringify({code: 400, errno: 102, error: "doesn't exist"});
let server = httpd_setup({
"/account/login": function(request, response) {
let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
let jsonBody = JSON.parse(body);
if (jsonBody.email == "mé@example.com") {
do_check_eq(jsonBody.authPW, "08b9d111196b8408e8ed92439da49206c8ecfbf343df0ae1ecefcd1e0174a8b6");
response.setStatusLine(request.httpVersion, 200, "OK");
response.bodyOutputStream.write(sessionMessage, sessionMessage.length);
},
"/recovery_email/status": function(request, response) {
return response.bodyOutputStream.write(sessionMessage, sessionMessage.length);
}
// Error trying to sign in to nonexistent account
response.setStatusLine(request.httpVersion, 400, "Bad request");
return response.bodyOutputStream.write(errorMessage, errorMessage.length);
},
});
let client = new FxAccountsClient(server.baseURI);
let result = yield client.signIn('mé@example.com', 'bigsecret');
do_check_eq(FAKE_SESSION_TOKEN, result.sessionToken);
// Trigger error path
try {
result = yield client.signIn("yøü@bad.example.org", "nofear");
} catch(expectedError) {
do_check_eq(102, expectedError.errno);
}
yield deferredStop(server);
});
add_task(function test_signOut() {
let signoutMessage = JSON.stringify({});
let errorMessage = JSON.stringify({code: 400, errno: 102, error: "doesn't exist"});
let signedOut = false;
let server = httpd_setup({
"/session/destroy": function(request, response) {
if (!signedOut) {
signedOut = true;
do_check_true(request.hasHeader("Authorization"));
response.setStatusLine(request.httpVersion, 200, "OK");
response.bodyOutputStream.write(emailStatus, emailStatus.length);
},
"/session/destroy": function(request, response) {
do_check_true(request.hasHeader("Authorization"));
return response.bodyOutputStream.write(signoutMessage, signoutMessage.length);
}
// Error trying to sign out of nonexistent account
response.setStatusLine(request.httpVersion, 400, "Bad request");
return response.bodyOutputStream.write(errorMessage, errorMessage.length);
},
});
let client = new FxAccountsClient(server.baseURI);
let result = yield client.signOut("FakeSession");
do_check_eq(typeof result, "object");
// Trigger error path
try {
result = yield client.signOut("FakeSession");
} catch(expectedError) {
do_check_eq(102, expectedError.errno);
}
yield deferredStop(server);
});
add_task(function test_recoveryEmailStatus() {
let emailStatus = JSON.stringify({verified: true});
let errorMessage = JSON.stringify({code: 400, errno: 102, error: "doesn't exist"});
let tries = 0;
let server = httpd_setup({
"/recovery_email/status": function(request, response) {
do_check_true(request.hasHeader("Authorization"));
if (tries === 0) {
response.setStatusLine(request.httpVersion, 200, "OK");
response.bodyOutputStream.write(signoutMessage, signoutMessage.length);
},
"/certificate/sign": function(request, response) {
do_check_true(request.hasHeader("Authorization"));
return response.bodyOutputStream.write(emailStatus, emailStatus.length);
}
// Second call gets an error trying to query a nonexistent account
response.setStatusLine(request.httpVersion, 400, "Bad request");
return response.bodyOutputStream.write(errorMessage, errorMessage.length);
},
});
let client = new FxAccountsClient(server.baseURI);
let result = yield client.recoveryEmailStatus(FAKE_SESSION_TOKEN);
do_check_eq(result.verified, true);
// Trigger error path
try {
result = yield client.recoveryEmailStatus("some bogus session");
} catch(expectedError) {
do_check_eq(102, expectedError.errno);
}
yield deferredStop(server);
});
add_task(function test_resendVerificationEmail() {
let emptyMessage = "{}";
let errorMessage = JSON.stringify({code: 400, errno: 102, error: "doesn't exist"});
let tries = 0;
let server = httpd_setup({
"/recovery_email/resend_code": function(request, response) {
do_check_true(request.hasHeader("Authorization"));
if (tries === 0) {
response.setStatusLine(request.httpVersion, 200, "OK");
return response.bodyOutputStream.write(emptyMessage, emptyMessage.length);
}
// Second call gets an error trying to query a nonexistent account
response.setStatusLine(request.httpVersion, 400, "Bad request");
return response.bodyOutputStream.write(errorMessage, errorMessage.length);
},
});
let client = new FxAccountsClient(server.baseURI);
let result = yield client.resendVerificationEmail(FAKE_SESSION_TOKEN);
do_check_eq(JSON.stringify(result), emptyMessage);
// Trigger error path
try {
result = yield client.resendVerificationEmail("some bogus session");
} catch(expectedError) {
do_check_eq(102, expectedError.errno);
}
yield deferredStop(server);
});
add_task(function test_accountKeys() {
// Vectors: https://wiki.mozilla.org/Identity/AttachedServices/KeyServerProtocol#.2Faccount.2Fkeys
let keyFetch = h("8081828384858687 88898a8b8c8d8e8f"+
"9091929394959697 98999a9b9c9d9e9f");
let response = h("ee5c58845c7c9412 b11bbd20920c2fdd"+
"d83c33c9cd2c2de2 d66b222613364636"+
"c2c0f8cfbb7c6304 72c0bd88451342c6"+
"c05b14ce342c5ad4 6ad89e84464c993c"+
"3927d30230157d08 17a077eef4b20d97"+
"6f7a97363faf3f06 4c003ada7d01aa70");
let kA = h("2021222324252627 28292a2b2c2d2e2f"+
"3031323334353637 38393a3b3c3d3e3f");
let wrapKB = h("4041424344454647 48494a4b4c4d4e4f"+
"5051525354555657 58595a5b5c5d5e5f");
let responseMessage = JSON.stringify({bundle: response});
let errorMessage = JSON.stringify({code: 400, errno: 102, error: "doesn't exist"});
let emptyMessage = "{}";
let attempt = 0;
let server = httpd_setup({
"/account/keys": function(request, response) {
do_check_true(request.hasHeader("Authorization"));
attempt += 1;
switch(attempt) {
case 1:
// First time succeeds
response.setStatusLine(request.httpVersion, 200, "OK");
response.bodyOutputStream.write(responseMessage, responseMessage.length);
break;
case 2:
// Second time, return no bundle to trigger client error
response.setStatusLine(request.httpVersion, 200, "OK");
response.bodyOutputStream.write(emptyMessage, emptyMessage.length);
break;
case 3:
// Return gibberish to trigger client MAC error
let garbage = response;
garbage[0] = 0; // tweak a byte
response.setStatusLine(request.httpVersion, 200, "OK");
response.bodyOutputStream.write(responseMessage, responseMessage.length);
break;
case 4:
// Trigger error for nonexistent account
response.setStatusLine(request.httpVersion, 400, "Bad request");
response.bodyOutputStream.write(errorMessage, errorMessage.length);
break;
}
},
});
let client = new FxAccountsClient(server.baseURI);
// First try, all should be good
let result = yield client.accountKeys(keyFetch);
do_check_eq(CommonUtils.hexToBytes(kA), result.kA);
do_check_eq(CommonUtils.hexToBytes(wrapKB), result.wrapKB);
// Second try, empty bundle should trigger error
try {
result = yield client.accountKeys(keyFetch);
} catch(expectedError) {
do_check_eq(expectedError.message, "failed to retrieve keys");
}
// Third try, bad bundle results in MAC error
try {
result = yield client.accountKeys(keyFetch);
} catch(expectedError) {
do_check_eq(expectedError.message, "error unbundling encryption keys");
}
// Fourth try, pretend account doesn't exist
try {
result = yield client.accountKeys(keyFetch);
} catch(expectedError) {
do_check_eq(102, expectedError.errno);
}
yield deferredStop(server);
});
add_task(function test_signCertificate() {
let certSignMessage = JSON.stringify({cert: {bar: "baz"}});
let errorMessage = JSON.stringify({code: 400, errno: 102, error: "doesn't exist"});
let tries = 0;
let server = httpd_setup({
"/certificate/sign": function(request, response) {
do_check_true(request.hasHeader("Authorization"));
if (tries === 0) {
tries += 1;
let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
let jsonBody = JSON.parse(body);
do_check_eq(JSON.parse(jsonBody.publicKey).foo, "bar");
do_check_eq(jsonBody.duration, 600);
response.setStatusLine(request.httpVersion, 200, "OK");
response.bodyOutputStream.write(certSignMessage, certSignMessage.length);
},
"/auth/start": function(request, response) {
if (authStarts === 0) {
response.setStatusLine(request.httpVersion, 200, "OK");
writeResp(response, JSON.stringify({}));
} else if (authStarts === 1) {
response.setStatusLine(request.httpVersion, 400, "NOT OK");
writeResp(response, JSON.stringify({errno: 102, error: "no such account"}));
} else if (authStarts === 2) {
response.setStatusLine(request.httpVersion, 400, "NOT OK");
writeResp(response, JSON.stringify({errno: 107, error: "boom"}));
}
authStarts++;
},
}
);
return response.bodyOutputStream.write(certSignMessage, certSignMessage.length);
}
// Second attempt, trigger error
response.setStatusLine(request.httpVersion, 400, "Bad request");
response.bodyOutputStream.write(errorMessage, errorMessage.length);
},
});
let client = new FxAccountsClient(server.baseURI);
let result = undefined;
result = yield client.signUp('you@example.com', 'biggersecret');
do_check_eq("NotARealUid", result.uid);
result = yield client.signIn('mé@example.com', 'bigsecret');
do_check_eq("NotARealToken", result.sessionToken);
result = yield client.signOut(FAKE_SESSION_TOKEN);
do_check_eq(typeof result, "object");
result = yield client.recoveryEmailStatus('NotARealToken');
do_check_eq(result.verified, true);
result = yield client.signCertificate('NotARealToken', JSON.stringify({foo: "bar"}), 600);
let result = yield client.signCertificate(FAKE_SESSION_TOKEN, JSON.stringify({foo: "bar"}), 600);
do_check_eq("baz", result.bar);
result = yield client.accountExists('hey@example.com');
do_check_eq(result, true);
result = yield client.accountExists('hey2@example.com');
do_check_eq(result, false);
// Account doesn't exist
try {
result = yield client.accountExists('hey3@example.com');
} catch(e) {
do_check_eq(e.errno, 107);
result = yield client.signCertificate("bogus", JSON.stringify({foo: "bar"}), 600);
} catch(expectedError) {
do_check_eq(102, expectedError.errno);
}
yield deferredStop(server);
});
add_task(function test_error_response() {
let errorMessage = JSON.stringify({error: "Oops", code: 400, errno: 99});
add_task(function test_accountExists() {
let sessionMessage = JSON.stringify({sessionToken: FAKE_SESSION_TOKEN});
let existsMessage = JSON.stringify({error: "wrong password", code: 400, errno: 103});
let doesntExistMessage = JSON.stringify({error: "no such account", code: 400, errno: 102});
let emptyMessage = "{}";
let server = httpd_setup(
{
"/raw_password/session/create": function(request, response) {
let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
let server = httpd_setup({
"/account/login": function(request, response) {
let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
let jsonBody = JSON.parse(body);
response.setStatusLine(request.httpVersion, 400, "NOT OK");
response.bodyOutputStream.write(errorMessage, errorMessage.length);
},
}
);
switch (jsonBody.email) {
// We'll test that these users' accounts exist
case "i.exist@example.com":
case "i.also.exist@example.com":
response.setStatusLine(request.httpVersion, 400, "Bad request");
response.bodyOutputStream.write(existsMessage, existsMessage.length);
break;
// This user's account doesn't exist
case "i.dont.exist@example.com":
response.setStatusLine(request.httpVersion, 400, "Bad request");
response.bodyOutputStream.write(doesntExistMessage, doesntExistMessage.length);
break;
// This user throws an unexpected response
// This will reject the client signIn promise
case "i.break.things@example.com":
response.setStatusLine(request.httpVersion, 500, "Alas");
response.bodyOutputStream.write(emptyMessage, emptyMessage.length);
break;
default:
throw new Error("Unexpected login from " + jsonBody.email);
break;
}
},
});
let client = new FxAccountsClient(server.baseURI);
let result;
try {
let result = yield client.signIn('mé@example.com', 'bigsecret');
} catch(result) {
do_check_eq("Oops", result.error);
do_check_eq(400, result.code);
do_check_eq(99, result.errno);
result = yield client.accountExists("i.exist@example.com");
} catch(expectedError) {
do_check_eq(expectedError.code, 400);
do_check_eq(expectedError.errno, 103);
}
try {
result = yield client.accountExists("i.also.exist@example.com");
} catch(expectedError) {
do_check_eq(expectedError.errno, 103);
}
try {
result = yield client.accountExists("i.dont.exist@example.com");
} catch(expectedError) {
do_check_eq(expectedError.errno, 102);
}
try {
result = yield client.accountExists("i.break.things@example.com");
} catch(unexpectedError) {
do_check_eq(unexpectedError.code, 500);
}
yield deferredStop(server);
});
// turn formatted test vectors into normal hex strings
function h(hexStr) {
return hexStr.replace(/\s+/g, "");
}

View File

@ -0,0 +1,120 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
Cu.import("resource://gre/modules/Credentials.jsm");
Cu.import("resource://gre/modules/Promise.jsm");
Cu.import("resource://services-common/utils.js");
Cu.import("resource://services-crypto/utils.js");
let {hexToBytes: h2b,
hexAsString: h2s,
stringAsHex: s2h,
bytesAsHex: b2h} = CommonUtils;
// Test vectors for the "onepw" protocol:
// https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol#wiki-test-vectors
let vectors = {
"client stretch-KDF": {
email:
h("616e6472c3a94065 78616d706c652e6f 7267"),
password:
h("70c3a4737377c3b6 7264"),
quickStretchedPW:
h("e4e8889bd8bd61ad 6de6b95c059d56e7 b50dacdaf62bd846 44af7e2add84345d"),
authPW:
h("247b675ffb4c4631 0bc87e26d712153a be5e1c90ef00a478 4594f97ef54f2375"),
authSalt:
h("00f0000000000000 0000000000000000 0000000000000000 0000000000000000"),
},
};
// A simple test suite with no utf8 encoding madness.
add_task(function test_onepw_setup_credentials() {
let email = "francine@example.org";
let password = CommonUtils.encodeUTF8("i like pie");
let pbkdf2 = CryptoUtils.pbkdf2Generate;
let hkdf = CryptoUtils.hkdf;
// quickStretch the email
let saltyEmail = Credentials.keyWordExtended("quickStretch", email);
do_check_eq(b2h(saltyEmail), "6964656e746974792e6d6f7a696c6c612e636f6d2f7069636c2f76312f717569636b537472657463683a6672616e63696e65406578616d706c652e6f7267");
let pbkdf2Rounds = 1000;
let pbkdf2Len = 32;
let quickStretchedPW = pbkdf2(password, saltyEmail, pbkdf2Rounds, pbkdf2Len, Ci.nsICryptoHMAC.SHA256, 32);
let quickStretchedActual = "6b88094c1c73bbf133223f300d101ed70837af48d9d2c1b6e7d38804b20cdde4";
do_check_eq(b2h(quickStretchedPW), quickStretchedActual);
// obtain hkdf info
let authKeyInfo = Credentials.keyWord('authPW');
do_check_eq(b2h(authKeyInfo), "6964656e746974792e6d6f7a696c6c612e636f6d2f7069636c2f76312f617574685057");
// derive auth password
let hkdfSalt = h2b("00");
let hkdfLen = 32;
let authPW = hkdf(quickStretchedPW, hkdfSalt, authKeyInfo, hkdfLen);
do_check_eq(b2h(authPW), "4b8dec7f48e7852658163601ff766124c312f9392af6c3d4e1a247eb439be342");
// derive unwrap key
let unwrapKeyInfo = Credentials.keyWord('unwrapBkey');
let unwrapKey = hkdf(quickStretchedPW, hkdfSalt, unwrapKeyInfo, hkdfLen);
do_check_eq(b2h(unwrapKey), "8ff58975be391338e4ec5d7138b5ed7b65c7d1bfd1f3a4f93e05aa47d5b72be9");
run_next_test();
});
add_task(function test_client_stretch_kdf() {
let pbkdf2 = CryptoUtils.pbkdf2Generate;
let hkdf = CryptoUtils.hkdf;
let expected = vectors["client stretch-KDF"];
let emailUTF8 = h2s(expected.email);
let passwordUTF8 = h2s(expected.password);
// Intermediate value from sjcl implementation in fxa-js-client
// The key thing is the c3a9 sequence in "andré"
let salt = Credentials.keyWordExtended("quickStretch", emailUTF8);
do_check_eq(b2h(salt), "6964656e746974792e6d6f7a696c6c612e636f6d2f7069636c2f76312f717569636b537472657463683a616e6472c3a9406578616d706c652e6f7267");
let options = {
stretchedPassLength: 32,
pbkdf2Rounds: 1000,
hmacAlgorithm: Ci.nsICryptoHMAC.SHA256,
hmacLength: 32,
hkdfSalt: h2b("00"),
hkdfLength: 32,
};
let results = yield Credentials.setup(emailUTF8, passwordUTF8, options);
do_check_eq(emailUTF8, results.emailUTF8,
"emailUTF8 is wrong");
do_check_eq(passwordUTF8, results.passwordUTF8,
"passwordUTF8 is wrong");
do_check_eq(expected.quickStretchedPW, b2h(results.quickStretchedPW),
"quickStretchedPW is wrong");
do_check_eq(expected.authPW, b2h(results.authPW),
"authPW is wrong");
run_next_test();
});
// End of tests
// Utility functions follow
function run_test() {
run_next_test();
}
// turn formatted test vectors into normal hex strings
function h(hexStr) {
return hexStr.replace(/\s+/g, "");
}

View File

@ -4,6 +4,7 @@ tail =
[test_accounts.js]
[test_client.js]
[test_credentials.js]
[test_manager.js]
run-if = appname == 'b2g'
reason = FxAccountsManager is only available for B2G for now