Bug 689428 - Part 1: Implement KeyExchange v3 in JPAKEClient. r=rnewman
authorPhilipp von Weitershausen <philipp@weitershausen.de>
Sun, 02 Oct 2011 01:15:39 -0700
changeset 78102 7b987cdc324f15d93fad0fbc94931285d7aae598
parent 78101 39e6453fbdef3d9f2b2a11c5b5cc7401ab7aebda
child 78103 4a12146b0685f9600a5dfbbb607c9211103c1e3e
push id21268
push userpweitershausen@mozilla.com
push dateTue, 04 Oct 2011 19:50:07 +0000
treeherdermozilla-central@70e4de45a0d0 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrnewman
bugs689428
milestone10.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 689428 - Part 1: Implement KeyExchange v3 in JPAKEClient. r=rnewman
services/sync/modules/constants.js
services/sync/modules/jpakeclient.js
services/sync/services-sync.js
services/sync/tests/unit/test_jpakeclient.js
--- a/services/sync/modules/constants.js
+++ b/services/sync/modules/constants.js
@@ -180,16 +180,17 @@ JPAKE_ERROR_NETWORK:                   "
 JPAKE_ERROR_SERVER:                    "jpake.error.server",
 JPAKE_ERROR_TIMEOUT:                   "jpake.error.timeout",
 JPAKE_ERROR_INTERNAL:                  "jpake.error.internal",
 JPAKE_ERROR_INVALID:                   "jpake.error.invalid",
 JPAKE_ERROR_NODATA:                    "jpake.error.nodata",
 JPAKE_ERROR_KEYMISMATCH:               "jpake.error.keymismatch",
 JPAKE_ERROR_WRONGMESSAGE:              "jpake.error.wrongmessage",
 JPAKE_ERROR_USERABORT:                 "jpake.error.userabort",
+JPAKE_ERROR_DELAYUNSUPPORTED:          "jpake.error.delayunsupported",
 
 // info types for Service.getStorageInfo
 INFO_COLLECTIONS:                      "collections",
 INFO_COLLECTION_USAGE:                 "collection_usage",
 INFO_COLLECTION_COUNTS:                "collection_counts",
 INFO_QUOTA:                            "quota",
 
 // Ways that a sync can be disabled (messages only to be printed in debug log)
--- a/services/sync/modules/jpakeclient.js
+++ b/services/sync/modules/jpakeclient.js
@@ -43,92 +43,112 @@ const Cu = Components.utils;
 Cu.import("resource://services-sync/log4moz.js");
 Cu.import("resource://services-sync/rest.js");
 Cu.import("resource://services-sync/constants.js");
 Cu.import("resource://services-sync/util.js");
 
 const EXPORTED_SYMBOLS = ["JPAKEClient"];
 
 const REQUEST_TIMEOUT         = 60; // 1 minute
+const KEYEXCHANGE_VERSION     = 3;
+
 const JPAKE_SIGNERID_SENDER   = "sender";
 const JPAKE_SIGNERID_RECEIVER = "receiver";
 const JPAKE_LENGTH_SECRET     = 8;
 const JPAKE_LENGTH_CLIENTID   = 256;
 const JPAKE_VERIFY_VALUE      = "0123456789ABCDEF";
 
 
-/*
+/**
  * Client to exchange encrypted data using the J-PAKE algorithm.
  * The exchange between two clients of this type looks like this:
  * 
  * 
- * Client A                      Server                      Client B
- * ==================================================================
- *                                  |
- * retrieve channel <---------------|
- * generate random secret           |
- * show PIN = secret + channel      |                ask user for PIN
- * upload A's message 1 ----------->|
- *                                  |--------> retrieve A's message 1
- *                                  |<---------- upload B's message 1
- * retrieve B's message 1 <---------|
- * upload A's message 2 ----------->|
- *                                  |--------> retrieve A's message 2
- *                                  |                     compute key
- *                                  |<---------- upload B's message 2
- * retrieve B's message 2 <---------|
- * compute key                      |
- * upload sha256d(key) ------------>|
- *                                  |---------> retrieve sha256d(key)
- *                                  |          verify against own key
- *                                  |                    encrypt data
- *                                  |<------------------- upload data
- * retrieve data <------------------|
- * verify HMAC                      |
- * decrypt data                     |
+ *  Mobile                        Server                        Desktop
+ *  ===================================================================
+ *                                   |
+ *  retrieve channel <---------------|
+ *  generate random secret           |
+ *  show PIN = secret + channel      |                 ask user for PIN
+ *  upload Mobile's message 1 ------>|
+ *                                   |----> retrieve Mobile's message 1
+ *                                   |<----- upload Desktop's message 1
+ *  retrieve Desktop's message 1 <---|
+ *  upload Mobile's message 2 ------>|
+ *                                   |----> retrieve Mobile's message 2
+ *                                   |                      compute key
+ *                                   |<----- upload Desktop's message 2
+ *  retrieve Desktop's message 2 <---|
+ *  compute key                      |
+ *  encrypt known value ------------>|
+ *                                   |-------> retrieve encrypted value
+ *                                   | verify against local known value
+ *
+ *   At this point Desktop knows whether the PIN was entered correctly.
+ *   If it wasn't, Desktop deletes the session. If it was, the account
+ *   setup can proceed. If Desktop doesn't yet have an account set up,
+ *   it will keep the channel open and let the user connect to or
+ *   create an account.
+ *
+ *                                   |              encrypt credentials
+ *                                   |<------------- upload credentials
+ *  retrieve credentials <-----------|
+ *  verify HMAC                      |
+ *  decrypt credentials              |
+ *  delete session ----------------->|
+ *  start syncing                    |
  * 
  * 
  * Create a client object like so:
  * 
- *   let client = new JPAKEClient(observer);
+ *   let client = new JPAKEClient(controller);
  * 
- * The 'observer' object must implement the following methods:
+ * The 'controller' object must implement the following methods:
  * 
  *   displayPIN(pin) -- Display the PIN to the user, only called on the client
  *     that didn't provide the PIN.
  * 
+ *   onPaired() -- Called when the device pairing has been established and
+ *     we're ready to send the credentials over. To do that, the controller
+ *     must call 'sendAndComplete()' while the channel is active.
+ * 
  *   onComplete(data) -- Called after transfer has been completed. On
  *     the sending side this is called with no parameter and as soon as the
- *     data has been uploaded, which this doesn't mean the receiving side
- *     has actually retrieved them yet.
+ *     data has been uploaded. This does not mean the receiving side has
+ *     actually retrieved them yet.
  *
  *   onAbort(error) -- Called whenever an error is encountered. All errors lead
  *     to an abort and the process has to be started again on both sides.
  * 
  * To start the data transfer on the receiving side, call
  * 
  *   client.receiveNoPIN();
  * 
  * This will allocate a new channel on the server, generate a PIN, have it
  * displayed and then do the transfer once the protocol has been completed
  * with the sending side.
  * 
  * To initiate the transfer from the sending side, call
  * 
- *   client.sendWithPIN(pin, data)
+ *   client.pairWithPIN(pin, true);
+ * 
+ * Once the pairing has been established, the controller's 'onPaired()' method
+ * will be called. To then transmit the data, call
+ * 
+ *   client.sendAndComplete(data);
  * 
  * To abort the process, call
  * 
  *   client.abort();
  * 
  * Note that after completion or abort, the 'client' instance may not be reused.
  * You will have to create a new one in case you'd like to restart the process.
  */
-function JPAKEClient(observer) {
-  this.observer = observer;
+function JPAKEClient(controller) {
+  this.controller = controller;
 
   this._log = Log4Moz.repository.getLogger("Sync.JPAKEClient");
   this._log.level = Log4Moz.Level[Svc.Prefs.get(
     "log.logger.service.jpakeclient", "Debug")];
 
   this._serverURL = Svc.Prefs.get("jpake.serverURL");
   this._pollInterval = Svc.Prefs.get("jpake.pollInterval");
   this._maxTries = Svc.Prefs.get("jpake.maxTries");
@@ -144,16 +164,22 @@ function JPAKEClient(observer) {
 JPAKEClient.prototype = {
 
   _chain: Async.chain,
 
   /*
    * Public API
    */
 
+  /**
+   * Initiate pairing and receive data without providing a PIN. The PIN will
+   * be generated and passed on to the controller to be displayed to the user.
+   * 
+   * This is typically called on mobile devices where typing is tedious.
+   */
   receiveNoPIN: function receiveNoPIN() {
     this._my_signerid = JPAKE_SIGNERID_RECEIVER;
     this._their_signerid = JPAKE_SIGNERID_SENDER;
 
     this._secret = this._createSecret();
 
     // Allow a large number of tries first while we wait for the PIN
     // to be entered on the other device.
@@ -168,60 +194,113 @@ JPAKEClient.prototype = {
                   callback();
                 },
                 this._computeStepTwo,
                 this._putStep,
                 this._getStep,
                 this._computeFinal,
                 this._computeKeyVerification,
                 this._putStep,
+                function(callback) {
+                  // Allow longer time-out for the last message.
+                  this._maxTries = Svc.Prefs.get("jpake.lastMsgMaxTries");
+                  callback();
+                },
                 this._getStep,
                 this._decryptData,
                 this._complete)();
   },
 
-  sendWithPIN: function sendWithPIN(pin, obj) {
+  /**
+   * Initiate pairing based on the PIN entered by the user.
+   * 
+   * This is typically called on desktop devices where typing is easier than
+   * on mobile.
+   * 
+   * @param pin
+   *        12 character string (in human-friendly base32) containing the PIN
+   *        entered by the user.
+   * @param expectDelay
+   *        Flag that indicates that a significant delay between the pairing
+   *        and the sending should be expected. v2 and earlier of the protocol
+   *        did not allow for this and the pairing to a v2 or earlier client
+   *        will be aborted if this flag is 'true'.
+   */
+  pairWithPIN: function pairWithPIN(pin, expectDelay) {
     this._my_signerid = JPAKE_SIGNERID_SENDER;
     this._their_signerid = JPAKE_SIGNERID_RECEIVER;
 
     this._channel = pin.slice(JPAKE_LENGTH_SECRET);
     this._channelURL = this._serverURL + this._channel;
     this._secret = pin.slice(0, JPAKE_LENGTH_SECRET);
-    this._data = JSON.stringify(obj);
 
     this._chain(this._computeStepOne,
                 this._getStep,
+                function (callback) {
+                  // Ensure that the other client can deal with a delay for
+                  // the last message if that's requested by the caller.
+                  if (!expectDelay) {
+                    return callback();
+                  }
+                  if (!this._incoming.version || this._incoming.version < 3) {
+                    return this.abort(JPAKE_ERROR_DELAYUNSUPPORTED);
+                  }
+                  return callback();
+                },
                 this._putStep,
                 this._computeStepTwo,
                 this._getStep,
                 this._putStep,
                 this._computeFinal,
                 this._getStep,
-                this._encryptData,
+                this._verifyPairing)();
+  },
+
+  /**
+   * Send data after a successful pairing.
+   * 
+   * @param obj
+   *        Object containing the data to send. It will be serialized as JSON.
+   */
+  sendAndComplete: function sendAndComplete(obj) {
+    if (!this._paired || this._finished) {
+      this._log.error("Can't send data, no active pairing!");
+      throw "No active pairing!";
+    }
+    this._data = JSON.stringify(obj);
+    this._chain(this._encryptData,
                 this._putStep,
                 this._complete)();
   },
 
+  /**
+   * Abort the current pairing. The channel on the server will be deleted
+   * if the abort wasn't due to a network or server error. The controller's
+   * 'onAbort()' method is notified in all cases.
+   * 
+   * @param error [optional]
+   *        Error constant indicating the reason for the abort. Defaults to
+   *        user abort.
+   */
   abort: function abort(error) {
     this._log.debug("Aborting...");
     this._finished = true;
     let self = this;
 
     // Default to "user aborted".
     if (!error) {
       error = JPAKE_ERROR_USERABORT;
     }
 
     if (error == JPAKE_ERROR_CHANNEL ||
         error == JPAKE_ERROR_NETWORK ||
         error == JPAKE_ERROR_NODATA) {
-      Utils.namedTimer(function() { this.observer.onAbort(error); }, 0,
-                       this, "_timer_onAbort");
+      Utils.nextTick(function() { this.controller.onAbort(error); }, this);
     } else {
-      this._reportFailure(error, function() { self.observer.onAbort(error); });
+      this._reportFailure(error, function() { self.controller.onAbort(error); });
     }
   },
 
   /*
    * Utilities
    */
 
   _setClientID: function _setClientID() {
@@ -280,26 +359,30 @@ JPAKEClient.prototype = {
         this.abort(JPAKE_ERROR_CHANNEL);
         return;
       }
       this._log.debug("Using channel " + this._channel);
       this._channelURL = this._serverURL + this._channel;
 
       // Don't block on UI code.
       let pin = this._secret + this._channel;
-      Utils.namedTimer(function() { this.observer.displayPIN(pin); }, 0,
-                       this, "_timer_displayPIN");
+      Utils.nextTick(function() { this.controller.displayPIN(pin); }, this);
       callback();
     }));
   },
 
   // Generic handler for uploading data.
   _putStep: function _putStep(callback) {
     this._log.trace("Uploading message " + this._outgoing.type);
     let request = this._newRequest(this._channelURL);
+    if (this._their_etag) {
+      request.setHeader("If-Match", this._their_etag);
+    } else {
+      request.setHeader("If-None-Match", "*");
+    }
     request.put(this._outgoing, Utils.bind2(this, function (error) {
       if (this._finished) {
         return;
       }
 
       if (error) {
         this._log.error("Error uploading data. " + error);
         this.abort(JPAKE_ERROR_NETWORK);
@@ -308,29 +391,29 @@ JPAKEClient.prototype = {
       if (request.response.status != 200) {
         this._log.error("Could not upload data. Server responded with HTTP "
                         + request.response.status);
         this.abort(JPAKE_ERROR_SERVER);
         return;
       }
       // There's no point in returning early here since the next step will
       // always be a GET so let's pause for twice the poll interval.
-      this._etag = request.response.headers["etag"];
+      this._my_etag = request.response.headers["etag"];
       Utils.namedTimer(function () { callback(); }, this._pollInterval * 2,
                        this, "_pollTimer");
     }));
   },
 
   // Generic handler for polling for and retrieving data.
   _pollTries: 0,
   _getStep: function _getStep(callback) {
     this._log.trace("Retrieving next message.");
     let request = this._newRequest(this._channelURL);
-    if (this._etag) {
-      request.setHeader("If-None-Match", this._etag);
+    if (this._my_etag) {
+      request.setHeader("If-None-Match", this._my_etag);
     }
 
     request.get(Utils.bind2(this, function (error) {
       if (this._finished) {
         return;
       }
 
       if (error) {
@@ -360,16 +443,24 @@ JPAKEClient.prototype = {
       }
       if (request.response.status != 200) {
         this._log.error("Could not retrieve data. Server responded with HTTP "
                         + request.response.status);
         this.abort(JPAKE_ERROR_SERVER);
         return;
       }
 
+      this._their_etag = request.response.headers["etag"];
+      if (!this._their_etag) {
+        this._log.error("Server did not supply ETag for message: "
+                        + request.response.body);
+        this.abort(JPAKE_ERROR_SERVER);
+        return;
+      }
+
       try {
         this._incoming = JSON.parse(request.response.body);
       } catch (ex) {
         this._log.error("Server responded with invalid JSON.");
         this.abort(JPAKE_ERROR_INVALID);
         return;
       }
       this._log.trace("Fetched message " + this._incoming.type);
@@ -409,17 +500,19 @@ JPAKEClient.prototype = {
       this._log.error("JPAKE round 1 threw: " + ex);
       this.abort(JPAKE_ERROR_INTERNAL);
       return;
     }
     let one = {gx1: gx1.value,
                gx2: gx2.value,
                zkp_x1: {gr: gv1.value, b: r1.value, id: this._my_signerid},
                zkp_x2: {gr: gv2.value, b: r2.value, id: this._my_signerid}};
-    this._outgoing = {type: this._my_signerid + "1", payload: one};
+    this._outgoing = {type: this._my_signerid + "1",
+                      version: KEYEXCHANGE_VERSION,
+                      payload: one};
     this._log.trace("Generated message " + this._outgoing.type);
     callback();
   },
 
   _computeStepTwo: function _computeStepTwo(callback) {
     this._log.trace("Computing round 2.");
     if (this._incoming.type != this._their_signerid + "1") {
       this._log.error("Invalid round 1 message: "
@@ -447,17 +540,19 @@ JPAKEClient.prototype = {
                          A, gvA, rA);
     } catch (ex) {
       this._log.error("JPAKE round 2 threw: " + ex);
       this.abort(JPAKE_ERROR_INTERNAL);
       return;
     }
     let two = {A: A.value,
                zkp_A: {gr: gvA.value, b: rA.value, id: this._my_signerid}};
-    this._outgoing = {type: this._my_signerid + "2", payload: two};
+    this._outgoing = {type: this._my_signerid + "2",
+                      version: KEYEXCHANGE_VERSION,
+                      payload: two};
     this._log.trace("Generated message " + this._outgoing.type);
     callback();
   },
 
   _computeFinal: function _computeFinal(callback) {
     if (this._incoming.type != this._their_signerid + "2") {
       this._log.error("Invalid round 2 message: "
                       + JSON.stringify(this._incoming));
@@ -499,54 +594,64 @@ JPAKEClient.prototype = {
       ciphertext = Svc.Crypto.encrypt(JPAKE_VERIFY_VALUE,
                                       this._crypto_key, iv);
     } catch (ex) {
       this._log.error("Failed to encrypt key verification value.");
       this.abort(JPAKE_ERROR_INTERNAL);
       return;
     }
     this._outgoing = {type: this._my_signerid + "3",
+                      version: KEYEXCHANGE_VERSION,
                       payload: {ciphertext: ciphertext, IV: iv}};
     this._log.trace("Generated message " + this._outgoing.type);
     callback();
   },
 
-  _encryptData: function _encryptData(callback) {
+  _verifyPairing: function _verifyPairing(callback) {
     this._log.trace("Verifying their key.");
     if (this._incoming.type != this._their_signerid + "3") {
       this._log.error("Invalid round 3 data: " +
                       JSON.stringify(this._incoming));
       this.abort(JPAKE_ERROR_WRONGMESSAGE);
       return;
     }
     let step3 = this._incoming.payload;
+    let ciphertext;
     try {
       ciphertext = Svc.Crypto.encrypt(JPAKE_VERIFY_VALUE,
                                       this._crypto_key, step3.IV);
       if (ciphertext != step3.ciphertext) {
         throw "Key mismatch!";
       }
     } catch (ex) {
       this._log.error("Keys don't match!");
       this.abort(JPAKE_ERROR_KEYMISMATCH);
       return;
     }
 
+    this._log.debug("Verified pairing!");
+    this._paired = true;
+    Utils.nextTick(function () { this.controller.onPaired(); }, this);
+    callback();
+  },
+
+  _encryptData: function _encryptData(callback) {
     this._log.trace("Encrypting data.");
     let iv, ciphertext, hmac;
     try {
       iv = Svc.Crypto.generateRandomIV();
       ciphertext = Svc.Crypto.encrypt(this._data, this._crypto_key, iv);
       hmac = Utils.bytesAsHex(Utils.digestUTF8(ciphertext, this._hmac_hasher));
     } catch (ex) {
       this._log.error("Failed to encrypt data.");
       this.abort(JPAKE_ERROR_INTERNAL);
       return;
     }
     this._outgoing = {type: this._my_signerid + "3",
+                      version: KEYEXCHANGE_VERSION,
                       payload: {ciphertext: ciphertext, IV: iv, hmac: hmac}};
     this._log.trace("Generated message " + this._outgoing.type);
     callback();
   },
 
   _decryptData: function _decryptData(callback) {
     this._log.trace("Verifying their key.");
     if (this._incoming.type != this._their_signerid + "3") {
@@ -589,13 +694,13 @@ JPAKEClient.prototype = {
 
     this._log.trace("Decrypted data.");
     callback();
   },
 
   _complete: function _complete() {
     this._log.debug("Exchange completed.");
     this._finished = true;
-    Utils.namedTimer(function () { this.observer.onComplete(this._newData); },
-                     0, this, "_timer_onComplete");
+    Utils.nextTick(function () { this.controller.onComplete(this._newData); },
+                   this);
   }
 
 };
--- a/services/sync/services-sync.js
+++ b/services/sync/services-sync.js
@@ -21,17 +21,18 @@ pref("services.sync.engine.bookmarks", t
 pref("services.sync.engine.history", true);
 pref("services.sync.engine.passwords", true);
 pref("services.sync.engine.prefs", true);
 pref("services.sync.engine.tabs", true);
 pref("services.sync.engine.tabs.filteredUrls", "^(about:.*|chrome://weave/.*|wyciwyg:.*|file:.*)$");
 
 pref("services.sync.jpake.serverURL", "https://setup.services.mozilla.com/");
 pref("services.sync.jpake.pollInterval", 1000);
-pref("services.sync.jpake.firstMsgMaxTries", 300);
+pref("services.sync.jpake.firstMsgMaxTries", 300); // 5 minutes
+pref("services.sync.jpake.lastMsgMaxTries", 300);  // 5 minutes
 pref("services.sync.jpake.maxTries", 10);
 
 pref("services.sync.log.appender.console", "Warn");
 pref("services.sync.log.appender.dump", "Error");
 pref("services.sync.log.appender.file.level", "Trace");
 pref("services.sync.log.appender.file.logOnError", true);
 pref("services.sync.log.appender.file.logOnSuccess", false);
 pref("services.sync.log.appender.file.maxErrorAge", 864000); // 10 days
--- a/services/sync/tests/unit/test_jpakeclient.js
+++ b/services/sync/tests/unit/test_jpakeclient.js
@@ -1,36 +1,42 @@
 Cu.import("resource://services-sync/log4moz.js");
 Cu.import("resource://services-sync/identity.js");
 Cu.import("resource://services-sync/jpakeclient.js");
 Cu.import("resource://services-sync/constants.js");
 Cu.import("resource://services-sync/util.js");
 
 const JPAKE_LENGTH_SECRET     = 8;
 const JPAKE_LENGTH_CLIENTID   = 256;
+const KEYEXCHANGE_VERSION     = 3;
 
 /*
  * Simple server.
  */
 
+const SERVER_MAX_GETS = 6;
+
 function check_headers(request) {
+  let stack = Components.stack.caller;
+
   // There shouldn't be any Basic auth
-  do_check_false(request.hasHeader("Authorization"));
+  do_check_false(request.hasHeader("Authorization"), stack);
 
   // Ensure key exchange ID is set and the right length
-  do_check_true(request.hasHeader("X-KeyExchange-Id"));
+  do_check_true(request.hasHeader("X-KeyExchange-Id"), stack);
   do_check_eq(request.getHeader("X-KeyExchange-Id").length,
-              JPAKE_LENGTH_CLIENTID);
+              JPAKE_LENGTH_CLIENTID, stack);
 }
 
 function new_channel() {
   // Create a new channel and register it with the server.
   let cid = Math.floor(Math.random() * 10000);
-  while (channels[cid])
+  while (channels[cid]) {
     cid = Math.floor(Math.random() * 10000);
+  }
   let channel = channels[cid] = new ServerChannel();
   server.registerPathHandler("/" + cid, channel.handler());
   return cid;
 }
 
 let server;
 let channels = {};  // Map channel -> ServerChannel object
 function server_new_channel(request, response) {
@@ -40,60 +46,79 @@ function server_new_channel(request, res
   response.setStatusLine(request.httpVersion, 200, "OK");
   response.bodyOutputStream.write(body, body.length);
 }
 
 let error_report;
 function server_report(request, response) {
   check_headers(request);
 
-  if (request.hasHeader("X-KeyExchange-Log"))
+  if (request.hasHeader("X-KeyExchange-Log")) {
     error_report = request.getHeader("X-KeyExchange-Log");
+  }
 
   if (request.hasHeader("X-KeyExchange-Cid")) {
     let cid = request.getHeader("X-KeyExchange-Cid");
     let channel = channels[cid];
-    if (channel)
+    if (channel) {
       channel.clear();
+    }
   }
 
   response.setStatusLine(request.httpVersion, 200, "OK");
 }
 
 function ServerChannel() {
-  this.data = "{}";
+  this.data = "";
+  this.etag = "";
   this.getCount = 0;
 }
 ServerChannel.prototype = {
 
   GET: function GET(request, response) {
     if (!this.data) {
       response.setStatusLine(request.httpVersion, 404, "Not Found");
       return;
     }
+
     if (request.hasHeader("If-None-Match")) {
       let etag = request.getHeader("If-None-Match");
-      if (etag == this._etag) {
+      if (etag == this.etag) {
         response.setStatusLine(request.httpVersion, 304, "Not Modified");
         return;
       }
     }
+    response.setHeader("ETag", this.etag);
     response.setStatusLine(request.httpVersion, 200, "OK");
     response.bodyOutputStream.write(this.data, this.data.length);
 
     // Automatically clear the channel after 6 successful GETs.
     this.getCount += 1;
-    if (this.getCount == 6)
+    if (this.getCount == SERVER_MAX_GETS) {
       this.clear();
+    }
   },
 
   PUT: function PUT(request, response) {
+    if (this.data) {
+      do_check_true(request.hasHeader("If-Match"));
+      let etag = request.getHeader("If-Match");
+      if (etag != this.etag) {
+        response.setHeader("ETag", this.etag);
+        response.setStatusLine(request.httpVersion, 412, "Precondition Failed");
+        return;
+      }
+    } else {
+      do_check_true(request.hasHeader("If-None-Match"));
+      do_check_eq(request.getHeader("If-None-Match"), "*");
+    }
+
     this.data = readBytesFromInputStream(request.bodyInputStream);
-    this._etag = '"' + Utils.sha1(this.data) + '"';
-    response.setHeader("ETag", this._etag);
+    this.etag = '"' + Utils.sha1(this.data) + '"';
+    response.setHeader("ETag", this.etag);
     response.setStatusLine(request.httpVersion, 200, "OK");
   },
 
   clear: function clear() {
     delete this.data;
   },
 
   handler: function handler() {
@@ -103,24 +128,44 @@ ServerChannel.prototype = {
       let method = self[request.method];
       return method.apply(self, arguments);
     };
   }
 
 };
 
 
+/**
+ * Controller that throws for everything.
+ */
+let BaseController = {
+  displayPIN: function displayPIN() {
+    do_throw("displayPIN() shouldn't have been called!");
+  },
+  onAbort: function onAbort(error) {
+    do_throw("Shouldn't have aborted with " + error + "!");
+  },
+  onPaired: function onPaired() {
+    do_throw("onPaired() shouldn't have been called!");
+  },
+  onComplete: function onComplete(data) {
+    do_throw("Shouldn't have completed with " + data + "!");
+  }
+};
+
+
 const DATA = {"msg": "eggstreamly sekrit"};
 const POLLINTERVAL = 50;
 
 function run_test() {
   Svc.Prefs.set("jpake.serverURL", "http://localhost:8080/");
   Svc.Prefs.set("jpake.pollInterval", POLLINTERVAL);
-  Svc.Prefs.set("jpake.maxTries", 5);
+  Svc.Prefs.set("jpake.maxTries", 2);
   Svc.Prefs.set("jpake.firstMsgMaxTries", 5);
+  Svc.Prefs.set("jpake.lastMsgMaxTries", 5);
   // Ensure clean up
   Svc.Obs.add("profile-before-change", function() {
     Svc.Prefs.resetBranch("");
   });
 
   // Ensure PSM is initialized.
   Cc["@mozilla.org/psm;1"].getService(Ci.nsISupports);
 
@@ -129,125 +174,185 @@ function run_test() {
   let id = new Identity(PWDMGR_PASSWORD_REALM, "johndoe");
   id.password = "ilovejane";
   ID.set("WeaveID", id);
 
   server = httpd_setup({"/new_channel": server_new_channel,
                         "/report":      server_report});
 
   initTestLogging("Trace");
+  Log4Moz.repository.getLogger("Sync.JPAKEClient").level = Log4Moz.Level.Trace;
+  Log4Moz.repository.getLogger("Sync.RESTRequest").level = Log4Moz.Level.Trace;
   run_next_test();
 }
 
 
 add_test(function test_success_receiveNoPIN() {
   _("Test a successful exchange started by receiveNoPIN().");
 
   let snd = new JPAKEClient({
-    displayPIN: function displayPIN() {
-      do_throw("displayPIN shouldn't have been called!");
-    },
-    onAbort: function onAbort(error) {
-      do_throw("Shouldn't have aborted!" + error);
+    __proto__: BaseController,
+    onPaired: function onPaired() {
+      _("Pairing successful, sending final payload.");
+      Utils.nextTick(function() { snd.sendAndComplete(DATA); });
     },
     onComplete: function onComplete() {}
   });
 
   let rec = new JPAKEClient({
+    __proto__: BaseController,
     displayPIN: function displayPIN(pin) {
       _("Received PIN " + pin + ". Entering it in the other computer...");
       this.cid = pin.slice(JPAKE_LENGTH_SECRET);
-      Utils.nextTick(function() { snd.sendWithPIN(pin, DATA); });
-    },
-    onAbort: function onAbort(error) {
-      do_throw("Shouldn't have aborted! " + error);
+      Utils.nextTick(function() { snd.pairWithPIN(pin, false); });
     },
     onComplete: function onComplete(a) {
       // Ensure channel was cleared, no error report.
       do_check_eq(channels[this.cid].data, undefined);
       do_check_eq(error_report, undefined);
       run_next_test();
     }
   });
   rec.receiveNoPIN();
 });
 
 
-add_test(function test_firstMsgMaxTries() {
+add_test(function test_firstMsgMaxTries_timeout() {
   _("Test abort when sender doesn't upload anything.");
 
   let rec = new JPAKEClient({
+    __proto__: BaseController,
     displayPIN: function displayPIN(pin) {
       _("Received PIN " + pin + ". Doing nothing...");
       this.cid = pin.slice(JPAKE_LENGTH_SECRET);
     },
     onAbort: function onAbort(error) {
       do_check_eq(error, JPAKE_ERROR_TIMEOUT);
       // Ensure channel was cleared, error report was sent.
       do_check_eq(channels[this.cid].data, undefined);
       do_check_eq(error_report, JPAKE_ERROR_TIMEOUT);
       error_report = undefined;
       run_next_test();
+    }
+  });
+  rec.receiveNoPIN();
+});
+
+
+add_test(function test_firstMsgMaxTries() {
+  _("Test that receiver can wait longer for the first message.");
+
+  let snd = new JPAKEClient({
+    __proto__: BaseController,
+    onPaired: function onPaired() {
+      _("Pairing successful, sending final payload.");
+      Utils.nextTick(function() { snd.sendAndComplete(DATA); });
     },
-    onComplete: function onComplete() {
-      do_throw("Shouldn't have completed! ");
+    onComplete: function onComplete() {}
+  });
+
+  let rec = new JPAKEClient({
+    __proto__: BaseController,
+    displayPIN: function displayPIN(pin) {
+      // For the purpose of the tests, the poll interval is 50ms and
+      // we're polling up to 5 times for the first exchange (as
+      // opposed to 2 times for most of the other exchanges). So let's
+      // pretend it took 150ms to enter the PIN on the sender.
+      _("Received PIN " + pin + ". Waiting 150ms before entering it into sender...");
+      this.cid = pin.slice(JPAKE_LENGTH_SECRET);
+      Utils.namedTimer(function() { snd.pairWithPIN(pin, false); },
+                       150, this, "_sendTimer");
+    },
+    onComplete: function onComplete(a) {
+      // Ensure channel was cleared, no error report.
+      do_check_eq(channels[this.cid].data, undefined);
+      do_check_eq(error_report, undefined);
+      run_next_test();
     }
   });
   rec.receiveNoPIN();
 });
 
 
+add_test(function test_lastMsgMaxTries() {
+  _("Test that receiver can wait longer for the last message.");
+
+ let snd = new JPAKEClient({
+    __proto__: BaseController,
+    onPaired: function onPaired() {
+      // For the purpose of the tests, the poll interval is 50ms and
+      // we're polling up to 5 times for the last exchange (as opposed
+      // to 2 times for other exchanges). So let's pretend it took
+      // 150ms to come up with the final payload, which should require
+      // 3 polls.
+      _("Pairing successful, waiting 150ms to send final payload.");
+      Utils.namedTimer(function() { snd.sendAndComplete(DATA); },
+                       150, this, "_sendTimer");
+    },
+    onComplete: function onComplete() {}
+  });
+
+  let rec = new JPAKEClient({
+    __proto__: BaseController,
+    displayPIN: function displayPIN(pin) {
+      _("Received PIN " + pin + ". Entering it in the other computer...");
+      this.cid = pin.slice(JPAKE_LENGTH_SECRET);
+      Utils.nextTick(function() { snd.pairWithPIN(pin, false); });
+    },
+    onComplete: function onComplete(a) {
+      // Ensure channel was cleared, no error report.
+      do_check_eq(channels[this.cid].data, undefined);
+      do_check_eq(error_report, undefined);
+      run_next_test();
+    }
+  });
+
+  rec.receiveNoPIN();
+});
+
+
 add_test(function test_wrongPIN() {
   _("Test abort when PINs don't match.");
 
   let snd = new JPAKEClient({
-    displayPIN: function displayPIN() {
-      do_throw("displayPIN shouldn't have been called!");
-    },
+    __proto__: BaseController,
     onAbort: function onAbort(error) {
       do_check_eq(error, JPAKE_ERROR_KEYMISMATCH);
       do_check_eq(error_report, JPAKE_ERROR_KEYMISMATCH);
       error_report = undefined;
-    },
-    onComplete: function onComplete() {
-      do_throw("Shouldn't have completed!");
     }
   });
 
   let rec = new JPAKEClient({
+    __proto__: BaseController,
     displayPIN: function displayPIN(pin) {
       this.cid = pin.slice(JPAKE_LENGTH_SECRET);
       let secret = pin.slice(0, JPAKE_LENGTH_SECRET);
       secret = [char for each (char in secret)].reverse().join("");
       let new_pin = secret + this.cid;
       _("Received PIN " + pin + ", but I'm entering " + new_pin);
 
-      Utils.nextTick(function() { snd.sendWithPIN(new_pin, DATA); });
+      Utils.nextTick(function() { snd.pairWithPIN(new_pin, false); });
     },
     onAbort: function onAbort(error) {
       do_check_eq(error, JPAKE_ERROR_NODATA);
       // Ensure channel was cleared.
       do_check_eq(channels[this.cid].data, undefined);
       run_next_test();
-    },
-    onComplete: function onComplete() {
-      do_throw("Shouldn't have completed! ");
     }
   });
   rec.receiveNoPIN();
 });
 
 
 add_test(function test_abort_receiver() {
   _("Test user abort on receiving side.");
 
   let rec = new JPAKEClient({
-    onComplete: function onComplete(data) {
-      do_throw("onComplete shouldn't be called.");
-    },
+    __proto__: BaseController,
     onAbort: function onAbort(error) {
       // Manual abort = userabort.
       do_check_eq(error, JPAKE_ERROR_USERABORT);
       // Ensure channel was cleared.
       do_check_eq(channels[this.cid].data, undefined);
       do_check_eq(error_report, JPAKE_ERROR_USERABORT);
       error_report = undefined;
       run_next_test();
@@ -260,99 +365,141 @@ add_test(function test_abort_receiver() 
   rec.receiveNoPIN();
 });
 
 
 add_test(function test_abort_sender() {
   _("Test user abort on sending side.");
 
   let snd = new JPAKEClient({
-    displayPIN: function displayPIN() {
-      do_throw("displayPIN shouldn't have been called!");
-    },
+    __proto__: BaseController,
     onAbort: function onAbort(error) {
       // Manual abort == userabort.
       do_check_eq(error, JPAKE_ERROR_USERABORT);
       do_check_eq(error_report, JPAKE_ERROR_USERABORT);
       error_report = undefined;
-    },
-    onComplete: function onComplete() {
-      do_throw("Shouldn't have completed!");
     }
   });
 
   let rec = new JPAKEClient({
-    onComplete: function onComplete(data) {
-      do_throw("onComplete shouldn't be called.");
-    },
+    __proto__: BaseController,
     onAbort: function onAbort(error) {
       do_check_eq(error, JPAKE_ERROR_NODATA);
       // Ensure channel was cleared, no error report.
       do_check_eq(channels[this.cid].data, undefined);
       do_check_eq(error_report, undefined);
       run_next_test();
     },
     displayPIN: function displayPIN(pin) {
       _("Received PIN " + pin + ". Entering it in the other computer...");
       this.cid = pin.slice(JPAKE_LENGTH_SECRET);
-      Utils.nextTick(function() { snd.sendWithPIN(pin, DATA); });
+      Utils.nextTick(function() { snd.pairWithPIN(pin, false); });
       Utils.namedTimer(function() { snd.abort(); },
                        POLLINTERVAL, this, "_abortTimer");
     }
   });
   rec.receiveNoPIN();
 });
 
 
 add_test(function test_wrongmessage() {
   let cid = new_channel();
-  channels[cid].data = JSON.stringify({type: "receiver2", payload: {}});
+  let channel = channels[cid];
+  channel.data = JSON.stringify({type: "receiver2",
+                                 version: KEYEXCHANGE_VERSION,
+                                 payload: {}});
+  channel.etag = '"fake-etag"';
   let snd = new JPAKEClient({
+    __proto__: BaseController,
     onComplete: function onComplete(data) {
       do_throw("onComplete shouldn't be called.");
     },
     onAbort: function onAbort(error) {
       do_check_eq(error, JPAKE_ERROR_WRONGMESSAGE);
       run_next_test();
     }
   });
-  snd.sendWithPIN("01234567" + cid, DATA);
+  snd.pairWithPIN("01234567" + cid, false);
 });
 
 
 add_test(function test_error_channel() {
+  let serverURL = Svc.Prefs.get("jpake.serverURL");
   Svc.Prefs.set("jpake.serverURL", "http://localhost:12345/");
 
   let rec = new JPAKEClient({
-    onComplete: function onComplete(data) {
-      do_throw("onComplete shouldn't be called.");
-    },
+    __proto__: BaseController,
     onAbort: function onAbort(error) {
       do_check_eq(error, JPAKE_ERROR_CHANNEL);
-      Svc.Prefs.reset("jpake.serverURL");
+      Svc.Prefs.set("jpake.serverURL", serverURL);
       run_next_test();
     },
     displayPIN: function displayPIN(pin) {}
   });
   rec.receiveNoPIN();
 });
 
 
 add_test(function test_error_network() {
+  let serverURL = Svc.Prefs.get("jpake.serverURL");
   Svc.Prefs.set("jpake.serverURL", "http://localhost:12345/");
 
   let snd = new JPAKEClient({
-    onComplete: function onComplete(data) {
-      do_throw("onComplete shouldn't be called.");
-    },
+    __proto__: BaseController,
     onAbort: function onAbort(error) {
       do_check_eq(error, JPAKE_ERROR_NETWORK);
-      Svc.Prefs.reset("jpake.serverURL");
+      Svc.Prefs.set("jpake.serverURL", serverURL);
+      run_next_test();
+    }
+  });
+  snd.pairWithPIN("0123456789ab", false);
+});
+
+
+add_test(function test_error_server_noETag() {
+  let cid = new_channel();
+  let channel = channels[cid];
+  channel.data = JSON.stringify({type: "receiver1",
+                                 version: KEYEXCHANGE_VERSION,
+                                 payload: {}});
+  // This naughty server doesn't supply ETag (well, it supplies empty one).
+  channel.etag = "";
+  let snd = new JPAKEClient({
+    __proto__: BaseController,
+    onAbort: function onAbort(error) {
+      do_check_eq(error, JPAKE_ERROR_SERVER);
       run_next_test();
     }
   });
-  snd.sendWithPIN("0123456789ab", DATA);
+  snd.pairWithPIN("01234567" + cid, false);
+});
+
+
+add_test(function test_error_delayNotSupported() {
+  let cid = new_channel();
+  let channel = channels[cid];
+  channel.data = JSON.stringify({type: "receiver1",
+                                 version: 2,
+                                 payload: {}});
+  channel.etag = '"fake-etag"';
+  let snd = new JPAKEClient({
+    __proto__: BaseController,
+    onAbort: function onAbort(error) {
+      do_check_eq(error, JPAKE_ERROR_DELAYUNSUPPORTED);
+      run_next_test();
+    }
+  });
+  snd.pairWithPIN("01234567" + cid, true);
+});
+
+
+add_test(function test_sendAndComplete_notPaired() {
+  let snd = new JPAKEClient({__proto__: BaseController});
+  do_check_throws(function () {
+    snd.sendAndComplete(DATA);
+  });
+  run_next_test();
 });
 
 
 add_test(function tearDown() {
   server.stop(run_next_test);
 });