| author | Jed Parsons <jedp@mozilla.com> |
| Tue, 04 Feb 2014 22:14:30 -0800 | |
| changeset 167642 | 6e77dfcf5c6e376e86ea7ddb6d372c8d7fa836f0 |
| parent 167641 | b07709c0ea92b1db77e484e459065ddf28f72eae |
| child 167643 | 8e51e2b148fd459485d3249b5f4c5326fd1554d4 |
| child 167736 | fe097821c110823f6cd640372011eaeec3317d55 |
| push id | 26179 |
| push user | ttaubert@mozilla.com |
| push date | Sat, 08 Feb 2014 20:44:05 +0000 |
| treeherder | mozilla-central@8e51e2b148fd [default view] [failures only] |
| perfherder | [talos] [build metrics] [platform microbench] (compared to previous push) |
| reviewers | ckarlof |
| bugs | 943521 |
| milestone | 30.0a1 |
| first release with | nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
|
| last release without | nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
|
--- a/services/common/tests/unit/test_utils_convert_string.js +++ b/services/common/tests/unit/test_utils_convert_string.js @@ -1,15 +1,33 @@ /* Any copyright is dedicated to the Public Domain. http://creativecommons.org/publicdomain/zero/1.0/ */ "use strict"; 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(); } add_test(function test_compress_string() { const INPUT = "hello"; @@ -48,8 +66,75 @@ add_test(function test_bad_argument() { failed = true; do_check_true(ex.message.startsWith("Input string must be defined")); } finally { do_check_true(failed); } 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; +}
--- a/services/common/utils.js +++ b/services/common/utils.js @@ -191,32 +191,45 @@ this.CommonUtils = { return null; } }, byteArrayToString: function byteArrayToString(bytes) { 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) { let bytes = []; for (let i = 0; i < str.length - 1; i += 2) { bytes.push(parseInt(str.substr(i, 2), 16)); } return String.fromCharCode.apply(String, bytes); }, + hexAsString: function hexAsString(hex) { + return CommonUtils.decodeUTF8(CommonUtils.hexToBytes(hex)); + }, + /** * Base32 encode (RFC 4648) a string */ encodeBase32: function encodeBase32(bytes) { const key = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; let quanta = Math.floor(bytes.length / 5); let leftover = bytes.length % 5;
--- a/services/crypto/modules/utils.js +++ b/services/crypto/modules/utils.js @@ -162,30 +162,34 @@ this.CryptoUtils = { * The arguments to this function correspond to items in * PKCS #5, v2.0 pp. 9-10 * * P: the passphrase, an octet string: e.g., "secret phrase" * S: the salt, an octet string: e.g., "DNXPzPpiwn" * 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) { if (a.length != b.length) { return false; } let val = []; @@ -211,37 +215,37 @@ this.CryptoUtils = { I[3] = String.fromCharCode(i & 0xff); U[0] = CryptoUtils.digestBytes(S + I.join(''), h); for (let j = 1; j < c; j++) { U[j] = CryptoUtils.digestBytes(U[j - 1], h); } 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); return ret; }, deriveKeyFromPassphrase: function deriveKeyFromPassphrase(passphrase,
--- a/services/crypto/tests/unit/test_utils_pbkdf2.js +++ b/services/crypto/tests/unit/test_utils_pbkdf2.js @@ -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, ""); }
new file mode 100644 --- /dev/null +++ b/services/fxaccounts/Credentials.jsm @@ -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; + } +}); +
--- a/services/fxaccounts/FxAccountsClient.jsm +++ b/services/fxaccounts/FxAccountsClient.jsm @@ -8,52 +8,25 @@ const {classes: Cc, interfaces: Ci, util 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"); +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"; try { _host = Services.prefs.getCharPref("identity.fxaccounts.auth.uri"); } 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; // The FxA auth server expects requests to certain endpoints to be authorized // using Hawk. this.hawk = new HawkClient(host); }; @@ -87,55 +60,55 @@ this.FxAccountsClient.prototype = { * * @param email * The email address for the account (utf8) * @param password * The user's password * @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); }); }, /** * Authenticate and create a new session with the Firefox Account API server * * @param email * The email address for the account (utf8) * @param password * The user's password * @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); + }); }, /** * Destroy the current session with the Firefox Account API server * * @param sessionTokenHex * The session token encoded in hex * @return Promise @@ -172,25 +145,26 @@ this.FxAccountsClient.prototype = { /** * Retrieve encryption keys * * @param keyFetchTokenHex * A one-time use key fetch token encoded in hex * @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); return this._request("/account/keys", "GET", creds).then(resp => { if (!resp.bundle) { throw new Error("failed to retrieve keys"); } @@ -246,32 +220,35 @@ this.FxAccountsClient.prototype = { * * @param email * The email address to check * @return Promise * The promise resolves to true if the account exists, or false * 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; } - ); + } + ); }, /** * The FxA auth server expects requests to certain endpoints to be authorized using Hawk. * Hawk credentials are derived using shared secrets, which depend on the context * (e.g. sessionToken vs. keyFetchToken). * * @param tokenHex @@ -286,17 +263,17 @@ this.FxAccountsClient.prototype = { * algorithm: sha256 * id: the Hawk id (from the first 32 bytes derived) * key: the Hawk key (from bytes 32 to 64) * extra: size - 64 extra bytes * } */ _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", key: out.slice(32, 64), extra: out.slice(64), id: CommonUtils.bytesAsHex(out.slice(0, 32)) }; }, @@ -328,21 +305,23 @@ this.FxAccountsClient.prototype = { let deferred = Promise.defer(); this.hawk.request(path, method, credentials, jsonPayload).then( (responseText) => { try { 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); } ); return deferred.promise; }, };
--- a/services/fxaccounts/FxAccountsCommon.js +++ b/services/fxaccounts/FxAccountsCommon.js @@ -44,17 +44,17 @@ this.POLL_STEP = 1000 * 3; // Observer notifications. this.ONLOGIN_NOTIFICATION = "fxaccounts:onlogin"; this.ONVERIFIED_NOTIFICATION = "fxaccounts:onverified"; 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; this.ERRNO_NOT_VALID_JSON_BODY = 106; this.ERRNO_INVALID_BODY_PARAMETERS = 107; this.ERRNO_MISSING_BODY_PARAMETERS = 108; this.ERRNO_INVALID_REQUEST_SIGNATURE = 109; this.ERRNO_INVALID_AUTH_TOKEN = 110; @@ -63,17 +63,17 @@ this.ERRNO_MISSING_CONTENT_LENGTH = this.ERRNO_REQUEST_BODY_TOO_LARGE = 113; this.ERRNO_TOO_MANY_CLIENT_REQUESTS = 114; this.ERRNO_INVALID_AUTH_NONCE = 115; this.ERRNO_SERVICE_TEMP_UNAVAILABLE = 201; 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"; this.ERROR_INVALID_AUTH_TOKEN = "INVALID_AUTH_TOKEN"; this.ERROR_INVALID_AUTH_TIMESTAMP = "INVALID_AUTH_TIMESTAMP"; this.ERROR_INVALID_AUTH_NONCE = "INVALID_AUTH_NONCE"; this.ERROR_INVALID_BODY_PARAMETERS = "INVALID_BODY_PARAMETERS"; this.ERROR_INVALID_PASSWORD = "INVALID_PASSWORD"; @@ -91,17 +91,17 @@ this.ERROR_TOO_MANY_CLIENT_REQUESTS = this.ERROR_SERVICE_TEMP_UNAVAILABLE = "SERVICE_TEMPORARY_UNAVAILABLE"; this.ERROR_UI_ERROR = "UI_ERROR"; this.ERROR_UNKNOWN = "UNKNOWN_ERROR"; 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; SERVER_ERRNO_TO_ERROR[ERRNO_NOT_VALID_JSON_BODY] = ERROR_NOT_VALID_JSON_BODY; SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_BODY_PARAMETERS] = ERROR_INVALID_BODY_PARAMETERS; SERVER_ERRNO_TO_ERROR[ERRNO_MISSING_BODY_PARAMETERS] = ERROR_MISSING_BODY_PARAMETERS; SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_REQUEST_SIGNATURE] = ERROR_INVALID_REQUEST_SIGNATURE; SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_AUTH_TOKEN] = ERROR_INVALID_AUTH_TOKEN;
--- a/services/fxaccounts/moz.build +++ b/services/fxaccounts/moz.build @@ -4,16 +4,17 @@ # 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/. PARALLEL_DIRS += ['interfaces'] TEST_DIRS += ['tests'] EXTRA_JS_MODULES += [ + 'Credentials.jsm', 'FxAccounts.jsm', 'FxAccountsClient.jsm', 'FxAccountsCommon.js', 'FxAccountsUtils.jsm' ] # For now, we will only be using the FxA manager in B2G. if CONFIG['MOZ_B2G']:
--- a/services/fxaccounts/tests/xpcshell/test_client.js +++ b/services/fxaccounts/tests/xpcshell/test_client.js @@ -1,14 +1,15 @@ /* Any copyright is dedicated to the Public Domain. * http://creativecommons.org/publicdomain/zero/1.0/ */ 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"; function run_test() { run_next_test(); } function deferredStop(server) { @@ -96,136 +97,393 @@ add_task(function test_500_error() { } catch (e) { do_check_eq(500, e.code); do_check_eq("Internal Server Error", e.message); } 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}); - - let authStarts = 0; +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; - function writeResp(response, msg) { - response.bodyOutputStream.write(msg, msg.length); - } + let server = httpd_setup({ + "/account/create": function(request, response) { + let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream); + let jsonBody = JSON.parse(body); - 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"); + // 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"); - 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"); - } + 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); + } + + yield deferredStop(server); +}); + +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"); + 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"); - response.bodyOutputStream.write(signoutMessage, signoutMessage.length); - }, - "/certificate/sign": function(request, response) { - do_check_true(request.hasHeader("Authorization")); + 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({ + "/account/login": function(request, response) { + let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream); + let jsonBody = JSON.parse(body); + + 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; - let server = httpd_setup( - { - "/raw_password/session/create": function(request, response) { - let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream); + // 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; - response.setStatusLine(request.httpVersion, 400, "NOT OK"); - response.bodyOutputStream.write(errorMessage, errorMessage.length); - }, - } - ); + // 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 { + result = yield client.accountExists("i.exist@example.com"); + } catch(expectedError) { + do_check_eq(expectedError.code, 400); + do_check_eq(expectedError.errno, 103); + } 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.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, ""); +}
new file mode 100644 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_credentials.js @@ -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, ""); +}
--- a/services/fxaccounts/tests/xpcshell/xpcshell.ini +++ b/services/fxaccounts/tests/xpcshell/xpcshell.ini @@ -1,9 +1,10 @@ [DEFAULT] head = head.js ../../../common/tests/unit/head_helpers.js ../../../common/tests/unit/head_http.js 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