Bug 1426305 - Migrate FxA state machine to store only derived keys, not kB itself r=nalexander draft
authorGrigory Kruglov <gkruglov@mozilla.com>
Wed, 03 Jan 2018 16:08:06 -0500
changeset 715370 4564b7fa9837ccb8e2e9c99f706a4fa4cc95faa0
parent 715271 ac93fdadf1022211eec62258ad22b42cb37a6d14
child 744792 7f2ba604e40ad1c0bd0ff4e5a10853aa377fcc24
push id94159
push userbmo:gkruglov@mozilla.com
push dateWed, 03 Jan 2018 21:22:00 +0000
reviewersnalexander
bugs1426305
milestone59.0a1
Bug 1426305 - Migrate FxA state machine to store only derived keys, not kB itself r=nalexander MozReview-Commit-ID: 8npk7bTAYDA
mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountUtils.java
mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Cohabiting.java
mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Engaged.java
mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Married.java
mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/State.java
mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/StateFactory.java
mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/TokensAndKeysState.java
mobile/android/services/src/test/java/org/mozilla/gecko/background/fxa/test/TestFxAccountUtils.java
mobile/android/services/src/test/java/org/mozilla/gecko/fxa/login/TestStateFactory.java
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountUtils.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountUtils.java
@@ -94,28 +94,21 @@ public class FxAccountUtils {
    * @return x modulo N in hexadecimal.
    */
   public static String hexModN(BigInteger x, BigInteger N) {
     int byteLength = (N.bitLength() + 7) / 8;
     int hexLength = 2 * byteLength;
     return Utils.byte2Hex(Utils.hex2Byte((x.mod(N)).toString(16), byteLength), hexLength);
   }
 
-  /**
-   * The first engineering milestone of PICL (Profile-in-the-Cloud) was
-   * comprised of Sync 1.1 fronted by a Firefox Account. The sync key was
-   * generated from the Firefox Account password-derived kB value using this
-   * method.
-   */
-  public static KeyBundle generateSyncKeyBundle(final byte[] kB) throws InvalidKeyException, NoSuchAlgorithmException, UnsupportedEncodingException {
+  public static KeyBundle generateSyncKeyBundle(final byte[] kSync) throws InvalidKeyException, NoSuchAlgorithmException, UnsupportedEncodingException {
     byte[] encryptionKey = new byte[32];
     byte[] hmacKey = new byte[32];
-    byte[] derived = HKDF.derive(kB, new byte[0], FxAccountUtils.KW("oldsync"), 2*32);
-    System.arraycopy(derived, 0*32, encryptionKey, 0, 1*32);
-    System.arraycopy(derived, 1*32, hmacKey, 0, 1*32);
+    System.arraycopy(kSync, 0*32, encryptionKey, 0, 1*32);
+    System.arraycopy(kSync, 1*32, hmacKey, 0, 1*32);
     return new KeyBundle(encryptionKey, hmacKey);
   }
 
   /**
    * Firefox Accounts are password authenticated, but clients should not store
    * the plain-text password for any amount of time. Equivalent, but slightly
    * more secure, is the quickly client-side stretched password.
    * <p>
@@ -165,16 +158,26 @@ public class FxAccountUtils {
     byte[] kB = new byte[CRYPTO_KEY_LENGTH_BYTES];
     for (int i = 0; i < wrapkB.length; i++) {
       kB[i] = (byte) (wrapkB[i] ^ unwrapkB[i]);
     }
     return kB;
   }
 
   /**
+   * The first engineering milestone of PICL (Profile-in-the-Cloud) was
+   * comprised of Sync 1.1 fronted by a Firefox Account. The sync key was
+   * generated from the Firefox Account password-derived kB value using this
+   * method.
+   */
+  public static byte[] deriveSyncKey(byte[] kB) throws UnsupportedEncodingException, InvalidKeyException, NoSuchAlgorithmException {
+    return HKDF.derive(kB, new byte[0], FxAccountUtils.KW("oldsync"), 2*32);
+  }
+
+  /**
    * The token server accepts an X-Client-State header, which is the
    * lowercase-hex-encoded first 16 bytes of the SHA-256 hash of the
    * bytes of kB.
    * @param kB a byte array, expected to be 32 bytes long.
    * @return a 32-character string.
    * @throws NoSuchAlgorithmException
    */
   public static String computeClientState(byte[] kB) throws NoSuchAlgorithmException {
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Cohabiting.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Cohabiting.java
@@ -9,22 +9,22 @@ import org.mozilla.gecko.browserid.Brows
 import org.mozilla.gecko.browserid.JSONWebTokenUtils;
 import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.ExecuteDelegate;
 import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.LogMessage;
 import org.mozilla.gecko.sync.ExtendedJSONObject;
 
 public class Cohabiting extends TokensAndKeysState {
   private static final String LOG_TAG = Cohabiting.class.getSimpleName();
 
-  public Cohabiting(String email, String uid, byte[] sessionToken, byte[] kA, byte[] kB, BrowserIDKeyPair keyPair) {
-    super(StateLabel.Cohabiting, email, uid, sessionToken, kA, kB, keyPair);
+  /* package-private */ Cohabiting(String email, String uid, byte[] sessionToken, byte[] kSync, String kXCS, BrowserIDKeyPair keyPair) {
+    super(StateLabel.Cohabiting, email, uid, sessionToken, kSync, kXCS, keyPair);
   }
 
   public Married withCertificate(String certificate) {
-    return new Married(email, uid, sessionToken, kA, kB, keyPair, certificate);
+    return new Married(email, uid, sessionToken, kSync, kXCS, keyPair, certificate);
   }
 
   @Override
   public void execute(final ExecuteDelegate delegate) {
     delegate.getClient().sign(sessionToken, keyPair.getPublic().toJSONObject(), delegate.getCertificateDurationInMilliseconds(),
         new BaseRequestDelegate<String>(this, delegate) {
       @Override
       public void handleSuccess(String certificate) {
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Engaged.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Engaged.java
@@ -52,32 +52,39 @@ public class Engaged extends State {
       delegate.handleTransition(new LocalError(e), new Doghouse(email, uid, verified));
       return;
     }
     final BrowserIDKeyPair keyPair = theKeyPair;
 
     delegate.getClient().keys(keyFetchToken, new BaseRequestDelegate<TwoKeys>(this, delegate) {
       @Override
       public void handleSuccess(TwoKeys result) {
-        byte[] kB;
+        final byte[] kB;
+        final byte[] kSync;
+        final String kXCS;
         try {
           kB = FxAccountUtils.unwrapkB(unwrapkB, result.wrapkB);
+          // Only the derived keys move forward.
+          kSync = FxAccountUtils.deriveSyncKey(kB);
+          kXCS = FxAccountUtils.computeClientState(kB);
           if (FxAccountUtils.LOG_PERSONAL_INFORMATION) {
             FxAccountUtils.pii(LOG_TAG, "Fetched kA: " + Utils.byte2Hex(result.kA));
             FxAccountUtils.pii(LOG_TAG, "And wrapkB: " + Utils.byte2Hex(result.wrapkB));
-            FxAccountUtils.pii(LOG_TAG, "Giving kB : " + Utils.byte2Hex(kB));
+            FxAccountUtils.pii(LOG_TAG, "Unwrapped kB: " + Utils.byte2Hex(kB));
+            FxAccountUtils.pii(LOG_TAG, "Giving derived kSync: " + Utils.byte2Hex(kSync));
+            FxAccountUtils.pii(LOG_TAG, "Giving derived kXCS: " + kXCS);
           }
         } catch (Exception e) {
           delegate.handleTransition(new RemoteError(e), new Separated(email, uid, verified));
           return;
         }
         Transition transition = verified
             ? new LogMessage("keys succeeded")
             : new AccountVerified();
-        delegate.handleTransition(transition, new Cohabiting(email, uid, sessionToken, result.kA, kB, keyPair));
+        delegate.handleTransition(transition, new Cohabiting(email, uid, sessionToken, kSync, kXCS, keyPair));
       }
     });
   }
 
   @Override
   public Action getNeededAction() {
     if (!verified) {
       return Action.NeedsVerification;
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Married.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/Married.java
@@ -22,28 +22,21 @@ import org.mozilla.gecko.sync.ExtendedJS
 import org.mozilla.gecko.sync.NonObjectJSONException;
 import org.mozilla.gecko.sync.Utils;
 import org.mozilla.gecko.sync.crypto.KeyBundle;
 
 public class Married extends TokensAndKeysState {
   private static final String LOG_TAG = Married.class.getSimpleName();
 
   protected final String certificate;
-  protected final String clientState;
 
-  public Married(String email, String uid, byte[] sessionToken, byte[] kA, byte[] kB, BrowserIDKeyPair keyPair, String certificate) {
-    super(StateLabel.Married, email, uid, sessionToken, kA, kB, keyPair);
+  public Married(String email, String uid, byte[] sessionToken, byte[] kSync, String kXCS, BrowserIDKeyPair keyPair, String certificate) {
+    super(StateLabel.Married, email, uid, sessionToken, kSync, kXCS, keyPair);
     Utils.throwIfNull(certificate);
     this.certificate = certificate;
-    try {
-      this.clientState = FxAccountUtils.computeClientState(kB);
-    } catch (NoSuchAlgorithmException e) {
-      // This should never occur.
-      throw new IllegalStateException("Unable to compute client state from kB.");
-    }
   }
 
   @Override
   public ExtendedJSONObject toJSONObject() {
     ExtendedJSONObject o = super.toJSONObject();
     // Fields are non-null by constructor.
     o.put("certificate", certificate);
     return o;
@@ -95,23 +88,22 @@ public class Married extends TokensAndKe
       }
     } catch (Exception e) {
       FxAccountUtils.pii(LOG_TAG, "Got exception dumping assertion debug info.");
     }
     return assertion;
   }
 
   public KeyBundle getSyncKeyBundle() throws InvalidKeyException, NoSuchAlgorithmException, UnsupportedEncodingException {
-    // TODO Document this choice for deriving from kB.
-    return FxAccountUtils.generateSyncKeyBundle(kB);
+    return FxAccountUtils.generateSyncKeyBundle(kSync);
   }
 
   public String getClientState() {
     if (FxAccountUtils.LOG_PERSONAL_INFORMATION) {
-      FxAccountUtils.pii(LOG_TAG, "Client state: " + this.clientState);
+      FxAccountUtils.pii(LOG_TAG, "Client state: " + this.kXCS);
     }
-    return this.clientState;
+    return this.kXCS;
   }
 
   public Cohabiting makeCohabitingState() {
-    return new Cohabiting(email, uid, sessionToken, kA, kB, keyPair);
+    return new Cohabiting(email, uid, sessionToken, kSync, kXCS, keyPair);
   }
 }
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/State.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/State.java
@@ -4,23 +4,23 @@
 
 package org.mozilla.gecko.fxa.login;
 
 import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.ExecuteDelegate;
 import org.mozilla.gecko.sync.ExtendedJSONObject;
 import org.mozilla.gecko.sync.Utils;
 
 public abstract class State {
-  public static final long CURRENT_VERSION = 3L;
+  private static final long CURRENT_VERSION = 4L; // Bug 1426305.
 
   public static final class NotASessionTokenState extends Exception {
 
     private static final long serialVersionUID = 8628129091996684799L;
 
-    public NotASessionTokenState(String message) {
+    /* package-private */ NotASessionTokenState(String message) {
       super(message);
     }
   }
 
   public enum StateLabel {
     Engaged,
     Cohabiting,
     Married,
@@ -32,17 +32,17 @@ public abstract class State {
   public enum Action {
     NeedsUpgrade,
     NeedsPassword,
     NeedsVerification,
     NeedsFinishMigrating,
     None,
   }
 
-  protected final StateLabel stateLabel;
+  /* package-private */ final StateLabel stateLabel;
   public final String email;
   public final String uid;
   public final boolean verified;
 
   public State(StateLabel stateLabel, String email, String uid, boolean verified) {
     Utils.throwIfNull(email, uid);
     this.stateLabel = stateLabel;
     this.email = email;
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/StateFactory.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/StateFactory.java
@@ -1,14 +1,16 @@
 /* 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/. */
 
 package org.mozilla.gecko.fxa.login;
 
+import java.io.UnsupportedEncodingException;
+import java.security.InvalidKeyException;
 import java.security.NoSuchAlgorithmException;
 import java.security.spec.InvalidKeySpecException;
 
 import org.mozilla.gecko.background.common.log.Logger;
 import org.mozilla.gecko.background.fxa.FxAccountUtils;
 import org.mozilla.gecko.browserid.BrowserIDKeyPair;
 import org.mozilla.gecko.browserid.DSACryptoImplementation;
 import org.mozilla.gecko.browserid.RSACryptoImplementation;
@@ -42,62 +44,68 @@ public class StateFactory {
     return RSACryptoImplementation.fromJSONObject(o);
   }
 
   protected static BrowserIDKeyPair keyPairFromJSONObjectV2(ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException {
     // V2 key pairs are DSA.
     return DSACryptoImplementation.fromJSONObject(o);
   }
 
-  public static State fromJSONObject(StateLabel stateLabel, ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException, NonObjectJSONException {
+  public static State fromJSONObject(StateLabel stateLabel, ExtendedJSONObject o) throws InvalidKeySpecException, InvalidKeyException, UnsupportedEncodingException, NoSuchAlgorithmException, NonObjectJSONException {
     Long version = o.getLong("version");
     if (version == null) {
       throw new IllegalStateException("version must not be null");
     }
 
     final int v = version.intValue();
+    // The most common case is the most recent version.
+    if (v == 4) {
+      return fromJSONObjectV4(stateLabel, o);
+    }
     if (v == 3) {
-      // The most common case is the most recent version.
       return fromJSONObjectV3(stateLabel, o);
     }
     if (v == 2) {
       return fromJSONObjectV2(stateLabel, o);
     }
     if (v == 1) {
       final State state = fromJSONObjectV1(stateLabel, o);
       return migrateV1toV2(stateLabel, state);
     }
     throw new IllegalStateException("version must be in {1, 2}");
   }
 
-  protected static State fromJSONObjectV1(StateLabel stateLabel, ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException, NonObjectJSONException {
+  protected static State fromJSONObjectV1(StateLabel stateLabel, ExtendedJSONObject o) throws InvalidKeySpecException, InvalidKeyException, UnsupportedEncodingException, NoSuchAlgorithmException, NonObjectJSONException {
+    byte[] kB;
     switch (stateLabel) {
     case Engaged:
       return new Engaged(
           o.getString("email"),
           o.getString("uid"),
           o.getBoolean("verified"),
           Utils.hex2Byte(o.getString("unwrapkB")),
           Utils.hex2Byte(o.getString("sessionToken")),
           Utils.hex2Byte(o.getString("keyFetchToken")));
     case Cohabiting:
+      kB = Utils.hex2Byte(o.getString("kB"));
       return new Cohabiting(
           o.getString("email"),
           o.getString("uid"),
           Utils.hex2Byte(o.getString("sessionToken")),
-          Utils.hex2Byte(o.getString("kA")),
-          Utils.hex2Byte(o.getString("kB")),
+          FxAccountUtils.deriveSyncKey(kB),
+          FxAccountUtils.computeClientState(kB),
           keyPairFromJSONObjectV1(o.getObject("keyPair")));
     case Married:
+      kB = Utils.hex2Byte(o.getString("kB"));
       return new Married(
           o.getString("email"),
           o.getString("uid"),
           Utils.hex2Byte(o.getString("sessionToken")),
-          Utils.hex2Byte(o.getString("kA")),
-          Utils.hex2Byte(o.getString("kB")),
+          FxAccountUtils.deriveSyncKey(kB),
+          FxAccountUtils.computeClientState(kB),
           keyPairFromJSONObjectV1(o.getObject("keyPair")),
           o.getString("certificate"));
     case Separated:
       return new Separated(
           o.getString("email"),
           o.getString("uid"),
           o.getBoolean("verified"));
     case Doghouse:
@@ -106,100 +114,131 @@ public class StateFactory {
           o.getString("uid"),
           o.getBoolean("verified"));
     default:
       throw new IllegalStateException("unrecognized state label: " + stateLabel);
     }
   }
 
   /**
-   * Exactly the same as {@link fromJSONObjectV1}, except that all key pairs are DSA key pairs.
+   * Exactly the same as {@link StateFactory#fromJSONObjectV1}, except that all key pairs are DSA key pairs.
    */
-  protected static State fromJSONObjectV2(StateLabel stateLabel, ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException, NonObjectJSONException {
+  private static State fromJSONObjectV2(StateLabel stateLabel, ExtendedJSONObject o) throws InvalidKeySpecException, InvalidKeyException, UnsupportedEncodingException, NoSuchAlgorithmException, NonObjectJSONException {
+    byte[] kB;
     switch (stateLabel) {
     case Cohabiting:
+      kB = Utils.hex2Byte(o.getString("kB"));
       return new Cohabiting(
           o.getString("email"),
           o.getString("uid"),
           Utils.hex2Byte(o.getString("sessionToken")),
-          Utils.hex2Byte(o.getString("kA")),
-          Utils.hex2Byte(o.getString("kB")),
+          FxAccountUtils.deriveSyncKey(kB),
+          FxAccountUtils.computeClientState(kB),
           keyPairFromJSONObjectV2(o.getObject("keyPair")));
     case Married:
+      kB = Utils.hex2Byte(o.getString("kB"));
       return new Married(
           o.getString("email"),
           o.getString("uid"),
           Utils.hex2Byte(o.getString("sessionToken")),
-          Utils.hex2Byte(o.getString("kA")),
-          Utils.hex2Byte(o.getString("kB")),
+          FxAccountUtils.deriveSyncKey(kB),
+          FxAccountUtils.computeClientState(kB),
           keyPairFromJSONObjectV2(o.getObject("keyPair")),
           o.getString("certificate"));
     default:
       return fromJSONObjectV1(stateLabel, o);
     }
   }
 
   /**
-   * Exactly the same as {@link fromJSONObjectV2}, except that there's a new
+   * Exactly the same as {@link StateFactory#fromJSONObjectV2}, except that there's a new
    * MigratedFromSyncV11 state.
    */
-  protected static State fromJSONObjectV3(StateLabel stateLabel, ExtendedJSONObject o) throws InvalidKeySpecException, NoSuchAlgorithmException, NonObjectJSONException {
+  private static State fromJSONObjectV3(StateLabel stateLabel, ExtendedJSONObject o) throws InvalidKeySpecException, InvalidKeyException, UnsupportedEncodingException, NoSuchAlgorithmException, NonObjectJSONException {
     switch (stateLabel) {
     case MigratedFromSync11:
       return new MigratedFromSync11(
           o.getString("email"),
           o.getString("uid"),
           o.getBoolean("verified"),
           o.getString("password"));
     default:
       return fromJSONObjectV2(stateLabel, o);
     }
   }
 
-  protected static void logMigration(State from, State to) {
+  /**
+   * Exactly the same as {@link StateFactory#fromJSONObjectV3}, except instead of kB it contains
+   * derived kSync and kXCS.
+   */
+  private static State fromJSONObjectV4(StateLabel stateLabel, ExtendedJSONObject o) throws InvalidKeySpecException, InvalidKeyException, UnsupportedEncodingException, NoSuchAlgorithmException, NonObjectJSONException {
+    switch (stateLabel) {
+      case Cohabiting:
+        return new Cohabiting(
+                o.getString("email"),
+                o.getString("uid"),
+                Utils.hex2Byte(o.getString("sessionToken")),
+                Utils.hex2Byte(o.getString("kSync")),
+                o.getString("kXCS"),
+                keyPairFromJSONObjectV2(o.getObject("keyPair")));
+      case Married:
+        return new Married(
+                o.getString("email"),
+                o.getString("uid"),
+                Utils.hex2Byte(o.getString("sessionToken")),
+                Utils.hex2Byte(o.getString("kSync")),
+                o.getString("kXCS"),
+                keyPairFromJSONObjectV2(o.getObject("keyPair")),
+                o.getString("certificate"));
+      default:
+        return fromJSONObjectV3(stateLabel, o);
+    }
+  }
+
+  private static void logMigration(State from, State to) {
     if (!FxAccountUtils.LOG_PERSONAL_INFORMATION) {
       return;
     }
     try {
       FxAccountUtils.pii(LOG_TAG, "V1 persisted state is: " + from.toJSONObject().toJSONString());
     } catch (Exception e) {
       Logger.warn(LOG_TAG, "Error producing JSON representation of V1 state.", e);
     }
     FxAccountUtils.pii(LOG_TAG, "Generated new V2 state: " + to.toJSONObject().toJSONString());
   }
 
-  protected static State migrateV1toV2(StateLabel stateLabel, State state) throws NoSuchAlgorithmException {
+  private static State migrateV1toV2(StateLabel stateLabel, State state) throws NoSuchAlgorithmException {
     if (state == null) {
       // This should never happen, but let's be careful.
       Logger.error(LOG_TAG, "Got null state in migrateV1toV2; returning null.");
       return state;
     }
 
     Logger.info(LOG_TAG, "Migrating V1 persisted State to V2; stateLabel: " + stateLabel);
 
     // In V1, we use an RSA keyPair. In V2, we use a DSA keyPair. Only
     // Cohabiting and Married states have a persisted keyPair at all; all
     // other states need no conversion at all.
     switch (stateLabel) {
     case Cohabiting: {
       // In the Cohabiting state, we can just generate a new key pair and move on.
       final Cohabiting cohabiting = (Cohabiting) state;
       final BrowserIDKeyPair keyPair = generateKeyPair();
-      final State migrated = new Cohabiting(cohabiting.email, cohabiting.uid, cohabiting.sessionToken, cohabiting.kA, cohabiting.kB, keyPair);
+      final State migrated = new Cohabiting(cohabiting.email, cohabiting.uid, cohabiting.sessionToken, cohabiting.kSync, cohabiting.kXCS, keyPair);
       logMigration(cohabiting, migrated);
       return migrated;
     }
     case Married: {
       // In the Married state, we cannot only change the key pair: the stored
       // certificate signs the public key of the now obsolete key pair. We
       // regress to the Cohabiting state; the next time we sync, we should
       // advance back to Married.
       final Married married = (Married) state;
       final BrowserIDKeyPair keyPair = generateKeyPair();
-      final State migrated = new Cohabiting(married.email, married.uid, married.sessionToken, married.kA, married.kB, keyPair);
+      final State migrated = new Cohabiting(married.email, married.uid, married.sessionToken, married.kSync, married.kXCS, keyPair);
       logMigration(married, migrated);
       return migrated;
     }
     default:
       // Otherwise, V1 and V2 states are identical.
       return state;
     }
   }
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/TokensAndKeysState.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/login/TokensAndKeysState.java
@@ -5,36 +5,36 @@
 package org.mozilla.gecko.fxa.login;
 
 import org.mozilla.gecko.browserid.BrowserIDKeyPair;
 import org.mozilla.gecko.sync.ExtendedJSONObject;
 import org.mozilla.gecko.sync.Utils;
 
 public abstract class TokensAndKeysState extends State {
   protected final byte[] sessionToken;
-  protected final byte[] kA;
-  protected final byte[] kB;
+  /* package-private */ final byte[] kSync;
+  /* package-private */ final String kXCS;
   protected final BrowserIDKeyPair keyPair;
 
-  public TokensAndKeysState(StateLabel stateLabel, String email, String uid, byte[] sessionToken, byte[] kA, byte[] kB, BrowserIDKeyPair keyPair) {
+  /* package-private */ TokensAndKeysState(StateLabel stateLabel, String email, String uid, byte[] sessionToken, byte[] kSync, String kXCS, BrowserIDKeyPair keyPair) {
     super(stateLabel, email, uid, true);
-    Utils.throwIfNull(sessionToken, kA, kB, keyPair);
+    Utils.throwIfNull(sessionToken, kSync, kXCS, keyPair);
     this.sessionToken = sessionToken;
-    this.kA = kA;
-    this.kB = kB;
+    this.kSync = kSync;
+    this.kXCS = kXCS;
     this.keyPair = keyPair;
   }
 
   @Override
   public ExtendedJSONObject toJSONObject() {
     ExtendedJSONObject o = super.toJSONObject();
     // Fields are non-null by constructor.
     o.put("sessionToken", Utils.byte2Hex(sessionToken));
-    o.put("kA", Utils.byte2Hex(kA));
-    o.put("kB", Utils.byte2Hex(kB));
+    o.put("kSync", Utils.byte2Hex(kSync));
+    o.put("kXCS", kXCS);
     o.put("keyPair", keyPair.toJSONObject());
     return o;
   }
 
   @Override
   public byte[] getSessionToken() {
     return sessionToken;
   }
--- a/mobile/android/services/src/test/java/org/mozilla/gecko/background/fxa/test/TestFxAccountUtils.java
+++ b/mobile/android/services/src/test/java/org/mozilla/gecko/background/fxa/test/TestFxAccountUtils.java
@@ -69,17 +69,18 @@ public class TestFxAccountUtils {
 
     String expectedVHex = "00173ffa0263e63ccfd6791b8ee2a40f048ec94cd95aa8a3125726f9805e0c8283c658dc0b607fbb25db68e68e93f2658483049c68af7e8214c49fde2712a775b63e545160d64b00189a86708c69657da7a1678eda0cd79f86b8560ebdb1ffc221db360eab901d643a75bf1205070a5791230ae56466b8c3c1eb656e19b794f1ea0d2a077b3a755350208ea0118fec8c4b2ec344a05c66ae1449b32609ca7189451c259d65bd15b34d8729afdb5faff8af1f3437bbdc0c3d0b069a8ab2a959c90c5a43d42082c77490f3afcc10ef5648625c0605cdaace6c6fdc9e9a7e6635d619f50af7734522470502cab26a52a198f5b00a279858916507b0b4e9ef9524d6";
     Assert.assertEquals(expectedVHex, FxAccountUtils.hexModN(v, SRPConstants._2048.N));
   }
 
   @Test
   public void testGenerateSyncKeyBundle() throws Exception {
     byte[] kB = Utils.hex2Byte("d02d8fe39f28b601159c543f2deeb8f72bdf2043e8279aa08496fbd9ebaea361");
-    KeyBundle bundle = FxAccountUtils.generateSyncKeyBundle(kB);
+    byte[] kSync = FxAccountUtils.deriveSyncKey(kB);
+    KeyBundle bundle = FxAccountUtils.generateSyncKeyBundle(kSync);
     Assert.assertEquals("rsLwECkgPYeGbYl92e23FskfIbgld9TgeifEaB9ZwTI=", Base64.encodeBase64String(bundle.getEncryptionKey()));
     Assert.assertEquals("fs75EseCD/VOLodlIGmwNabBjhTYBHFCe7CGIf0t8Tw=", Base64.encodeBase64String(bundle.getHMACKey()));
   }
 
   @Test
   public void testGeneration() throws Exception {
     byte[] quickStretchedPW = FxAccountUtils.generateQuickStretchedPW(
         Utils.hex2Byte("616e6472c3a9406578616d706c652e6f7267"),
--- a/mobile/android/services/src/test/java/org/mozilla/gecko/fxa/login/TestStateFactory.java
+++ b/mobile/android/services/src/test/java/org/mozilla/gecko/fxa/login/TestStateFactory.java
@@ -1,16 +1,17 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 package org.mozilla.gecko.fxa.login;
 
 import junit.framework.Assert;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.fxa.FxAccountUtils;
 import org.mozilla.gecko.background.testhelpers.TestRunner;
 import org.mozilla.gecko.browserid.BrowserIDKeyPair;
 import org.mozilla.gecko.browserid.DSACryptoImplementation;
 import org.mozilla.gecko.fxa.login.State.StateLabel;
 import org.mozilla.gecko.sync.ExtendedJSONObject;
 import org.mozilla.gecko.sync.Utils;
 
 @RunWith(TestRunner.class)
@@ -19,53 +20,54 @@ public class TestStateFactory {
   public void testGetStateV3() throws Exception {
     MigratedFromSync11 migrated = new MigratedFromSync11("email", "uid", true, "password");
 
     // For the current version, we expect to read back what we wrote.
     ExtendedJSONObject o;
     State state;
 
     o = migrated.toJSONObject();
-    Assert.assertEquals(3, o.getLong("version").intValue());
+    Assert.assertEquals(4, o.getLong("version").intValue());
     state = StateFactory.fromJSONObject(migrated.stateLabel, o);
     Assert.assertEquals(StateLabel.MigratedFromSync11, state.stateLabel);
     Assert.assertEquals(o, state.toJSONObject());
 
     // Null passwords are OK.
     MigratedFromSync11 migratedNullPassword = new MigratedFromSync11("email", "uid", true, null);
 
     o = migratedNullPassword.toJSONObject();
-    Assert.assertEquals(3, o.getLong("version").intValue());
+    Assert.assertEquals(4, o.getLong("version").intValue());
     state = StateFactory.fromJSONObject(migratedNullPassword.stateLabel, o);
     Assert.assertEquals(StateLabel.MigratedFromSync11, state.stateLabel);
     Assert.assertEquals(o, state.toJSONObject());
   }
 
   @Test
   public void testGetStateV2() throws Exception {
     byte[] sessionToken = Utils.generateRandomBytes(32);
-    byte[] kA = Utils.generateRandomBytes(32);
     byte[] kB = Utils.generateRandomBytes(32);
+    byte[] kSync = FxAccountUtils.deriveSyncKey(kB);
+    String kXCS = FxAccountUtils.computeClientState(kB);
     BrowserIDKeyPair keyPair = DSACryptoImplementation.generateKeyPair(512);
-    Cohabiting cohabiting = new Cohabiting("email", "uid", sessionToken, kA, kB, keyPair);
+    Cohabiting cohabiting = new Cohabiting("email", "uid", sessionToken, kSync, kXCS, keyPair);
     String certificate = "certificate";
-    Married married = new Married("email", "uid", sessionToken, kA, kB, keyPair, certificate);
+    Married married = new Married("email", "uid", sessionToken, kSync, kXCS, keyPair, certificate);
 
     // For the current version, we expect to read back what we wrote.
     ExtendedJSONObject o;
     State state;
 
     o = married.toJSONObject();
-    Assert.assertEquals(3, o.getLong("version").intValue());
+    Assert.assertEquals(4, o.getLong("version").intValue());
     state = StateFactory.fromJSONObject(married.stateLabel, o);
     Assert.assertEquals(StateLabel.Married, state.stateLabel);
     Assert.assertEquals(o, state.toJSONObject());
 
     o = cohabiting.toJSONObject();
-    Assert.assertEquals(3, o.getLong("version").intValue());
+    Assert.assertEquals(4, o.getLong("version").intValue());
     state = StateFactory.fromJSONObject(cohabiting.stateLabel, o);
     Assert.assertEquals(StateLabel.Cohabiting, state.stateLabel);
     Assert.assertEquals(o, state.toJSONObject());
   }
 
   @Test
   public void testGetStateV1() throws Exception {
     // We can't rely on generating correct V1 objects (since the generation code