Bug 943521 - Use onepw prototcol in fxa client. r=ckarlof
authorJed Parsons <jedp@mozilla.com>
Tue, 04 Feb 2014 22:14:30 -0800
changeset 184833 6e77dfcf5c6e376e86ea7ddb6d372c8d7fa836f0
parent 184832 b07709c0ea92b1db77e484e459065ddf28f72eae
child 184834 8e51e2b148fd459485d3249b5f4c5326fd1554d4
child 184927 fe097821c110823f6cd640372011eaeec3317d55
push id3503
push userraliiev@mozilla.com
push dateMon, 28 Apr 2014 18:51:11 +0000
treeherdermozilla-beta@c95ac01e332e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersckarlof
bugs943521
milestone30.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
Bug 943521 - Use onepw prototcol in fxa client. r=ckarlof
services/common/tests/unit/test_utils_convert_string.js
services/common/utils.js
services/crypto/modules/utils.js
services/crypto/tests/unit/test_utils_pbkdf2.js
services/fxaccounts/Credentials.jsm
services/fxaccounts/FxAccountsClient.jsm
services/fxaccounts/FxAccountsCommon.js
services/fxaccounts/moz.build
services/fxaccounts/tests/xpcshell/test_client.js
services/fxaccounts/tests/xpcshell/test_credentials.js
services/fxaccounts/tests/xpcshell/xpcshell.ini
--- 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