Thu, 22 Jan 2015 13:21:57 +0100
Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6
michael@0 | 1 | /* This Source Code Form is subject to the terms of the Mozilla Public |
michael@0 | 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this |
michael@0 | 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ |
michael@0 | 4 | |
michael@0 | 5 | this.EXPORTED_SYMBOLS = ["JPAKEClient", "SendCredentialsController"]; |
michael@0 | 6 | |
michael@0 | 7 | const {classes: Cc, interfaces: Ci, results: Cr, utils: Cu} = Components; |
michael@0 | 8 | |
michael@0 | 9 | Cu.import("resource://gre/modules/Log.jsm"); |
michael@0 | 10 | Cu.import("resource://services-common/rest.js"); |
michael@0 | 11 | Cu.import("resource://services-sync/constants.js"); |
michael@0 | 12 | Cu.import("resource://services-sync/util.js"); |
michael@0 | 13 | |
michael@0 | 14 | const REQUEST_TIMEOUT = 60; // 1 minute |
michael@0 | 15 | const KEYEXCHANGE_VERSION = 3; |
michael@0 | 16 | |
michael@0 | 17 | const JPAKE_SIGNERID_SENDER = "sender"; |
michael@0 | 18 | const JPAKE_SIGNERID_RECEIVER = "receiver"; |
michael@0 | 19 | const JPAKE_LENGTH_SECRET = 8; |
michael@0 | 20 | const JPAKE_LENGTH_CLIENTID = 256; |
michael@0 | 21 | const JPAKE_VERIFY_VALUE = "0123456789ABCDEF"; |
michael@0 | 22 | |
michael@0 | 23 | |
michael@0 | 24 | /** |
michael@0 | 25 | * Client to exchange encrypted data using the J-PAKE algorithm. |
michael@0 | 26 | * The exchange between two clients of this type looks like this: |
michael@0 | 27 | * |
michael@0 | 28 | * |
michael@0 | 29 | * Mobile Server Desktop |
michael@0 | 30 | * =================================================================== |
michael@0 | 31 | * | |
michael@0 | 32 | * retrieve channel <---------------| |
michael@0 | 33 | * generate random secret | |
michael@0 | 34 | * show PIN = secret + channel | ask user for PIN |
michael@0 | 35 | * upload Mobile's message 1 ------>| |
michael@0 | 36 | * |----> retrieve Mobile's message 1 |
michael@0 | 37 | * |<----- upload Desktop's message 1 |
michael@0 | 38 | * retrieve Desktop's message 1 <---| |
michael@0 | 39 | * upload Mobile's message 2 ------>| |
michael@0 | 40 | * |----> retrieve Mobile's message 2 |
michael@0 | 41 | * | compute key |
michael@0 | 42 | * |<----- upload Desktop's message 2 |
michael@0 | 43 | * retrieve Desktop's message 2 <---| |
michael@0 | 44 | * compute key | |
michael@0 | 45 | * encrypt known value ------------>| |
michael@0 | 46 | * |-------> retrieve encrypted value |
michael@0 | 47 | * | verify against local known value |
michael@0 | 48 | * |
michael@0 | 49 | * At this point Desktop knows whether the PIN was entered correctly. |
michael@0 | 50 | * If it wasn't, Desktop deletes the session. If it was, the account |
michael@0 | 51 | * setup can proceed. If Desktop doesn't yet have an account set up, |
michael@0 | 52 | * it will keep the channel open and let the user connect to or |
michael@0 | 53 | * create an account. |
michael@0 | 54 | * |
michael@0 | 55 | * | encrypt credentials |
michael@0 | 56 | * |<------------- upload credentials |
michael@0 | 57 | * retrieve credentials <-----------| |
michael@0 | 58 | * verify HMAC | |
michael@0 | 59 | * decrypt credentials | |
michael@0 | 60 | * delete session ----------------->| |
michael@0 | 61 | * start syncing | |
michael@0 | 62 | * |
michael@0 | 63 | * |
michael@0 | 64 | * Create a client object like so: |
michael@0 | 65 | * |
michael@0 | 66 | * let client = new JPAKEClient(controller); |
michael@0 | 67 | * |
michael@0 | 68 | * The 'controller' object must implement the following methods: |
michael@0 | 69 | * |
michael@0 | 70 | * displayPIN(pin) -- Called when a PIN has been generated and is ready to |
michael@0 | 71 | * be displayed to the user. Only called on the client where the pairing |
michael@0 | 72 | * was initiated with 'receiveNoPIN()'. |
michael@0 | 73 | * |
michael@0 | 74 | * onPairingStart() -- Called when the pairing has started and messages are |
michael@0 | 75 | * being sent back and forth over the channel. Only called on the client |
michael@0 | 76 | * where the pairing was initiated with 'receiveNoPIN()'. |
michael@0 | 77 | * |
michael@0 | 78 | * onPaired() -- Called when the device pairing has been established and |
michael@0 | 79 | * we're ready to send the credentials over. To do that, the controller |
michael@0 | 80 | * must call 'sendAndComplete()' while the channel is active. |
michael@0 | 81 | * |
michael@0 | 82 | * onComplete(data) -- Called after transfer has been completed. On |
michael@0 | 83 | * the sending side this is called with no parameter and as soon as the |
michael@0 | 84 | * data has been uploaded. This does not mean the receiving side has |
michael@0 | 85 | * actually retrieved them yet. |
michael@0 | 86 | * |
michael@0 | 87 | * onAbort(error) -- Called whenever an error is encountered. All errors lead |
michael@0 | 88 | * to an abort and the process has to be started again on both sides. |
michael@0 | 89 | * |
michael@0 | 90 | * To start the data transfer on the receiving side, call |
michael@0 | 91 | * |
michael@0 | 92 | * client.receiveNoPIN(); |
michael@0 | 93 | * |
michael@0 | 94 | * This will allocate a new channel on the server, generate a PIN, have it |
michael@0 | 95 | * displayed and then do the transfer once the protocol has been completed |
michael@0 | 96 | * with the sending side. |
michael@0 | 97 | * |
michael@0 | 98 | * To initiate the transfer from the sending side, call |
michael@0 | 99 | * |
michael@0 | 100 | * client.pairWithPIN(pin, true); |
michael@0 | 101 | * |
michael@0 | 102 | * Once the pairing has been established, the controller's 'onPaired()' method |
michael@0 | 103 | * will be called. To then transmit the data, call |
michael@0 | 104 | * |
michael@0 | 105 | * client.sendAndComplete(data); |
michael@0 | 106 | * |
michael@0 | 107 | * To abort the process, call |
michael@0 | 108 | * |
michael@0 | 109 | * client.abort(); |
michael@0 | 110 | * |
michael@0 | 111 | * Note that after completion or abort, the 'client' instance may not be reused. |
michael@0 | 112 | * You will have to create a new one in case you'd like to restart the process. |
michael@0 | 113 | */ |
michael@0 | 114 | this.JPAKEClient = function JPAKEClient(controller) { |
michael@0 | 115 | this.controller = controller; |
michael@0 | 116 | |
michael@0 | 117 | this._log = Log.repository.getLogger("Sync.JPAKEClient"); |
michael@0 | 118 | this._log.level = Log.Level[Svc.Prefs.get( |
michael@0 | 119 | "log.logger.service.jpakeclient", "Debug")]; |
michael@0 | 120 | |
michael@0 | 121 | this._serverURL = Svc.Prefs.get("jpake.serverURL"); |
michael@0 | 122 | this._pollInterval = Svc.Prefs.get("jpake.pollInterval"); |
michael@0 | 123 | this._maxTries = Svc.Prefs.get("jpake.maxTries"); |
michael@0 | 124 | if (this._serverURL.slice(-1) != "/") { |
michael@0 | 125 | this._serverURL += "/"; |
michael@0 | 126 | } |
michael@0 | 127 | |
michael@0 | 128 | this._jpake = Cc["@mozilla.org/services-crypto/sync-jpake;1"] |
michael@0 | 129 | .createInstance(Ci.nsISyncJPAKE); |
michael@0 | 130 | |
michael@0 | 131 | this._setClientID(); |
michael@0 | 132 | } |
michael@0 | 133 | JPAKEClient.prototype = { |
michael@0 | 134 | |
michael@0 | 135 | _chain: Async.chain, |
michael@0 | 136 | |
michael@0 | 137 | /* |
michael@0 | 138 | * Public API |
michael@0 | 139 | */ |
michael@0 | 140 | |
michael@0 | 141 | /** |
michael@0 | 142 | * Initiate pairing and receive data without providing a PIN. The PIN will |
michael@0 | 143 | * be generated and passed on to the controller to be displayed to the user. |
michael@0 | 144 | * |
michael@0 | 145 | * This is typically called on mobile devices where typing is tedious. |
michael@0 | 146 | */ |
michael@0 | 147 | receiveNoPIN: function receiveNoPIN() { |
michael@0 | 148 | this._my_signerid = JPAKE_SIGNERID_RECEIVER; |
michael@0 | 149 | this._their_signerid = JPAKE_SIGNERID_SENDER; |
michael@0 | 150 | |
michael@0 | 151 | this._secret = this._createSecret(); |
michael@0 | 152 | |
michael@0 | 153 | // Allow a large number of tries first while we wait for the PIN |
michael@0 | 154 | // to be entered on the other device. |
michael@0 | 155 | this._maxTries = Svc.Prefs.get("jpake.firstMsgMaxTries"); |
michael@0 | 156 | this._chain(this._getChannel, |
michael@0 | 157 | this._computeStepOne, |
michael@0 | 158 | this._putStep, |
michael@0 | 159 | this._getStep, |
michael@0 | 160 | function(callback) { |
michael@0 | 161 | // We fetched the first response from the other client. |
michael@0 | 162 | // Notify controller of the pairing starting. |
michael@0 | 163 | Utils.nextTick(this.controller.onPairingStart, |
michael@0 | 164 | this.controller); |
michael@0 | 165 | |
michael@0 | 166 | // Now we can switch back to the smaller timeout. |
michael@0 | 167 | this._maxTries = Svc.Prefs.get("jpake.maxTries"); |
michael@0 | 168 | callback(); |
michael@0 | 169 | }, |
michael@0 | 170 | this._computeStepTwo, |
michael@0 | 171 | this._putStep, |
michael@0 | 172 | this._getStep, |
michael@0 | 173 | this._computeFinal, |
michael@0 | 174 | this._computeKeyVerification, |
michael@0 | 175 | this._putStep, |
michael@0 | 176 | function(callback) { |
michael@0 | 177 | // Allow longer time-out for the last message. |
michael@0 | 178 | this._maxTries = Svc.Prefs.get("jpake.lastMsgMaxTries"); |
michael@0 | 179 | callback(); |
michael@0 | 180 | }, |
michael@0 | 181 | this._getStep, |
michael@0 | 182 | this._decryptData, |
michael@0 | 183 | this._complete)(); |
michael@0 | 184 | }, |
michael@0 | 185 | |
michael@0 | 186 | /** |
michael@0 | 187 | * Initiate pairing based on the PIN entered by the user. |
michael@0 | 188 | * |
michael@0 | 189 | * This is typically called on desktop devices where typing is easier than |
michael@0 | 190 | * on mobile. |
michael@0 | 191 | * |
michael@0 | 192 | * @param pin |
michael@0 | 193 | * 12 character string (in human-friendly base32) containing the PIN |
michael@0 | 194 | * entered by the user. |
michael@0 | 195 | * @param expectDelay |
michael@0 | 196 | * Flag that indicates that a significant delay between the pairing |
michael@0 | 197 | * and the sending should be expected. v2 and earlier of the protocol |
michael@0 | 198 | * did not allow for this and the pairing to a v2 or earlier client |
michael@0 | 199 | * will be aborted if this flag is 'true'. |
michael@0 | 200 | */ |
michael@0 | 201 | pairWithPIN: function pairWithPIN(pin, expectDelay) { |
michael@0 | 202 | this._my_signerid = JPAKE_SIGNERID_SENDER; |
michael@0 | 203 | this._their_signerid = JPAKE_SIGNERID_RECEIVER; |
michael@0 | 204 | |
michael@0 | 205 | this._channel = pin.slice(JPAKE_LENGTH_SECRET); |
michael@0 | 206 | this._channelURL = this._serverURL + this._channel; |
michael@0 | 207 | this._secret = pin.slice(0, JPAKE_LENGTH_SECRET); |
michael@0 | 208 | |
michael@0 | 209 | this._chain(this._computeStepOne, |
michael@0 | 210 | this._getStep, |
michael@0 | 211 | function (callback) { |
michael@0 | 212 | // Ensure that the other client can deal with a delay for |
michael@0 | 213 | // the last message if that's requested by the caller. |
michael@0 | 214 | if (!expectDelay) { |
michael@0 | 215 | return callback(); |
michael@0 | 216 | } |
michael@0 | 217 | if (!this._incoming.version || this._incoming.version < 3) { |
michael@0 | 218 | return this.abort(JPAKE_ERROR_DELAYUNSUPPORTED); |
michael@0 | 219 | } |
michael@0 | 220 | return callback(); |
michael@0 | 221 | }, |
michael@0 | 222 | this._putStep, |
michael@0 | 223 | this._computeStepTwo, |
michael@0 | 224 | this._getStep, |
michael@0 | 225 | this._putStep, |
michael@0 | 226 | this._computeFinal, |
michael@0 | 227 | this._getStep, |
michael@0 | 228 | this._verifyPairing)(); |
michael@0 | 229 | }, |
michael@0 | 230 | |
michael@0 | 231 | /** |
michael@0 | 232 | * Send data after a successful pairing. |
michael@0 | 233 | * |
michael@0 | 234 | * @param obj |
michael@0 | 235 | * Object containing the data to send. It will be serialized as JSON. |
michael@0 | 236 | */ |
michael@0 | 237 | sendAndComplete: function sendAndComplete(obj) { |
michael@0 | 238 | if (!this._paired || this._finished) { |
michael@0 | 239 | this._log.error("Can't send data, no active pairing!"); |
michael@0 | 240 | throw "No active pairing!"; |
michael@0 | 241 | } |
michael@0 | 242 | this._data = JSON.stringify(obj); |
michael@0 | 243 | this._chain(this._encryptData, |
michael@0 | 244 | this._putStep, |
michael@0 | 245 | this._complete)(); |
michael@0 | 246 | }, |
michael@0 | 247 | |
michael@0 | 248 | /** |
michael@0 | 249 | * Abort the current pairing. The channel on the server will be deleted |
michael@0 | 250 | * if the abort wasn't due to a network or server error. The controller's |
michael@0 | 251 | * 'onAbort()' method is notified in all cases. |
michael@0 | 252 | * |
michael@0 | 253 | * @param error [optional] |
michael@0 | 254 | * Error constant indicating the reason for the abort. Defaults to |
michael@0 | 255 | * user abort. |
michael@0 | 256 | */ |
michael@0 | 257 | abort: function abort(error) { |
michael@0 | 258 | this._log.debug("Aborting..."); |
michael@0 | 259 | this._finished = true; |
michael@0 | 260 | let self = this; |
michael@0 | 261 | |
michael@0 | 262 | // Default to "user aborted". |
michael@0 | 263 | if (!error) { |
michael@0 | 264 | error = JPAKE_ERROR_USERABORT; |
michael@0 | 265 | } |
michael@0 | 266 | |
michael@0 | 267 | if (error == JPAKE_ERROR_CHANNEL || |
michael@0 | 268 | error == JPAKE_ERROR_NETWORK || |
michael@0 | 269 | error == JPAKE_ERROR_NODATA) { |
michael@0 | 270 | Utils.nextTick(function() { this.controller.onAbort(error); }, this); |
michael@0 | 271 | } else { |
michael@0 | 272 | this._reportFailure(error, function() { self.controller.onAbort(error); }); |
michael@0 | 273 | } |
michael@0 | 274 | }, |
michael@0 | 275 | |
michael@0 | 276 | /* |
michael@0 | 277 | * Utilities |
michael@0 | 278 | */ |
michael@0 | 279 | |
michael@0 | 280 | _setClientID: function _setClientID() { |
michael@0 | 281 | let rng = Cc["@mozilla.org/security/random-generator;1"] |
michael@0 | 282 | .createInstance(Ci.nsIRandomGenerator); |
michael@0 | 283 | let bytes = rng.generateRandomBytes(JPAKE_LENGTH_CLIENTID / 2); |
michael@0 | 284 | this._clientID = [("0" + byte.toString(16)).slice(-2) |
michael@0 | 285 | for each (byte in bytes)].join(""); |
michael@0 | 286 | }, |
michael@0 | 287 | |
michael@0 | 288 | _createSecret: function _createSecret() { |
michael@0 | 289 | // 0-9a-z without 1,l,o,0 |
michael@0 | 290 | const key = "23456789abcdefghijkmnpqrstuvwxyz"; |
michael@0 | 291 | let rng = Cc["@mozilla.org/security/random-generator;1"] |
michael@0 | 292 | .createInstance(Ci.nsIRandomGenerator); |
michael@0 | 293 | let bytes = rng.generateRandomBytes(JPAKE_LENGTH_SECRET); |
michael@0 | 294 | return [key[Math.floor(byte * key.length / 256)] |
michael@0 | 295 | for each (byte in bytes)].join(""); |
michael@0 | 296 | }, |
michael@0 | 297 | |
michael@0 | 298 | _newRequest: function _newRequest(uri) { |
michael@0 | 299 | let request = new RESTRequest(uri); |
michael@0 | 300 | request.setHeader("X-KeyExchange-Id", this._clientID); |
michael@0 | 301 | request.timeout = REQUEST_TIMEOUT; |
michael@0 | 302 | return request; |
michael@0 | 303 | }, |
michael@0 | 304 | |
michael@0 | 305 | /* |
michael@0 | 306 | * Steps of J-PAKE procedure |
michael@0 | 307 | */ |
michael@0 | 308 | |
michael@0 | 309 | _getChannel: function _getChannel(callback) { |
michael@0 | 310 | this._log.trace("Requesting channel."); |
michael@0 | 311 | let request = this._newRequest(this._serverURL + "new_channel"); |
michael@0 | 312 | request.get(Utils.bind2(this, function handleChannel(error) { |
michael@0 | 313 | if (this._finished) { |
michael@0 | 314 | return; |
michael@0 | 315 | } |
michael@0 | 316 | |
michael@0 | 317 | if (error) { |
michael@0 | 318 | this._log.error("Error acquiring channel ID. " + error); |
michael@0 | 319 | this.abort(JPAKE_ERROR_CHANNEL); |
michael@0 | 320 | return; |
michael@0 | 321 | } |
michael@0 | 322 | if (request.response.status != 200) { |
michael@0 | 323 | this._log.error("Error acquiring channel ID. Server responded with HTTP " |
michael@0 | 324 | + request.response.status); |
michael@0 | 325 | this.abort(JPAKE_ERROR_CHANNEL); |
michael@0 | 326 | return; |
michael@0 | 327 | } |
michael@0 | 328 | |
michael@0 | 329 | try { |
michael@0 | 330 | this._channel = JSON.parse(request.response.body); |
michael@0 | 331 | } catch (ex) { |
michael@0 | 332 | this._log.error("Server responded with invalid JSON."); |
michael@0 | 333 | this.abort(JPAKE_ERROR_CHANNEL); |
michael@0 | 334 | return; |
michael@0 | 335 | } |
michael@0 | 336 | this._log.debug("Using channel " + this._channel); |
michael@0 | 337 | this._channelURL = this._serverURL + this._channel; |
michael@0 | 338 | |
michael@0 | 339 | // Don't block on UI code. |
michael@0 | 340 | let pin = this._secret + this._channel; |
michael@0 | 341 | Utils.nextTick(function() { this.controller.displayPIN(pin); }, this); |
michael@0 | 342 | callback(); |
michael@0 | 343 | })); |
michael@0 | 344 | }, |
michael@0 | 345 | |
michael@0 | 346 | // Generic handler for uploading data. |
michael@0 | 347 | _putStep: function _putStep(callback) { |
michael@0 | 348 | this._log.trace("Uploading message " + this._outgoing.type); |
michael@0 | 349 | let request = this._newRequest(this._channelURL); |
michael@0 | 350 | if (this._their_etag) { |
michael@0 | 351 | request.setHeader("If-Match", this._their_etag); |
michael@0 | 352 | } else { |
michael@0 | 353 | request.setHeader("If-None-Match", "*"); |
michael@0 | 354 | } |
michael@0 | 355 | request.put(this._outgoing, Utils.bind2(this, function (error) { |
michael@0 | 356 | if (this._finished) { |
michael@0 | 357 | return; |
michael@0 | 358 | } |
michael@0 | 359 | |
michael@0 | 360 | if (error) { |
michael@0 | 361 | this._log.error("Error uploading data. " + error); |
michael@0 | 362 | this.abort(JPAKE_ERROR_NETWORK); |
michael@0 | 363 | return; |
michael@0 | 364 | } |
michael@0 | 365 | if (request.response.status != 200) { |
michael@0 | 366 | this._log.error("Could not upload data. Server responded with HTTP " |
michael@0 | 367 | + request.response.status); |
michael@0 | 368 | this.abort(JPAKE_ERROR_SERVER); |
michael@0 | 369 | return; |
michael@0 | 370 | } |
michael@0 | 371 | // There's no point in returning early here since the next step will |
michael@0 | 372 | // always be a GET so let's pause for twice the poll interval. |
michael@0 | 373 | this._my_etag = request.response.headers["etag"]; |
michael@0 | 374 | Utils.namedTimer(function () { callback(); }, this._pollInterval * 2, |
michael@0 | 375 | this, "_pollTimer"); |
michael@0 | 376 | })); |
michael@0 | 377 | }, |
michael@0 | 378 | |
michael@0 | 379 | // Generic handler for polling for and retrieving data. |
michael@0 | 380 | _pollTries: 0, |
michael@0 | 381 | _getStep: function _getStep(callback) { |
michael@0 | 382 | this._log.trace("Retrieving next message."); |
michael@0 | 383 | let request = this._newRequest(this._channelURL); |
michael@0 | 384 | if (this._my_etag) { |
michael@0 | 385 | request.setHeader("If-None-Match", this._my_etag); |
michael@0 | 386 | } |
michael@0 | 387 | |
michael@0 | 388 | request.get(Utils.bind2(this, function (error) { |
michael@0 | 389 | if (this._finished) { |
michael@0 | 390 | return; |
michael@0 | 391 | } |
michael@0 | 392 | |
michael@0 | 393 | if (error) { |
michael@0 | 394 | this._log.error("Error fetching data. " + error); |
michael@0 | 395 | this.abort(JPAKE_ERROR_NETWORK); |
michael@0 | 396 | return; |
michael@0 | 397 | } |
michael@0 | 398 | |
michael@0 | 399 | if (request.response.status == 304) { |
michael@0 | 400 | this._log.trace("Channel hasn't been updated yet. Will try again later."); |
michael@0 | 401 | if (this._pollTries >= this._maxTries) { |
michael@0 | 402 | this._log.error("Tried for " + this._pollTries + " times, aborting."); |
michael@0 | 403 | this.abort(JPAKE_ERROR_TIMEOUT); |
michael@0 | 404 | return; |
michael@0 | 405 | } |
michael@0 | 406 | this._pollTries += 1; |
michael@0 | 407 | Utils.namedTimer(function() { this._getStep(callback); }, |
michael@0 | 408 | this._pollInterval, this, "_pollTimer"); |
michael@0 | 409 | return; |
michael@0 | 410 | } |
michael@0 | 411 | this._pollTries = 0; |
michael@0 | 412 | |
michael@0 | 413 | if (request.response.status == 404) { |
michael@0 | 414 | this._log.error("No data found in the channel."); |
michael@0 | 415 | this.abort(JPAKE_ERROR_NODATA); |
michael@0 | 416 | return; |
michael@0 | 417 | } |
michael@0 | 418 | if (request.response.status != 200) { |
michael@0 | 419 | this._log.error("Could not retrieve data. Server responded with HTTP " |
michael@0 | 420 | + request.response.status); |
michael@0 | 421 | this.abort(JPAKE_ERROR_SERVER); |
michael@0 | 422 | return; |
michael@0 | 423 | } |
michael@0 | 424 | |
michael@0 | 425 | this._their_etag = request.response.headers["etag"]; |
michael@0 | 426 | if (!this._their_etag) { |
michael@0 | 427 | this._log.error("Server did not supply ETag for message: " |
michael@0 | 428 | + request.response.body); |
michael@0 | 429 | this.abort(JPAKE_ERROR_SERVER); |
michael@0 | 430 | return; |
michael@0 | 431 | } |
michael@0 | 432 | |
michael@0 | 433 | try { |
michael@0 | 434 | this._incoming = JSON.parse(request.response.body); |
michael@0 | 435 | } catch (ex) { |
michael@0 | 436 | this._log.error("Server responded with invalid JSON."); |
michael@0 | 437 | this.abort(JPAKE_ERROR_INVALID); |
michael@0 | 438 | return; |
michael@0 | 439 | } |
michael@0 | 440 | this._log.trace("Fetched message " + this._incoming.type); |
michael@0 | 441 | callback(); |
michael@0 | 442 | })); |
michael@0 | 443 | }, |
michael@0 | 444 | |
michael@0 | 445 | _reportFailure: function _reportFailure(reason, callback) { |
michael@0 | 446 | this._log.debug("Reporting failure to server."); |
michael@0 | 447 | let request = this._newRequest(this._serverURL + "report"); |
michael@0 | 448 | request.setHeader("X-KeyExchange-Cid", this._channel); |
michael@0 | 449 | request.setHeader("X-KeyExchange-Log", reason); |
michael@0 | 450 | request.post("", Utils.bind2(this, function (error) { |
michael@0 | 451 | if (error) { |
michael@0 | 452 | this._log.warn("Report failed: " + error); |
michael@0 | 453 | } else if (request.response.status != 200) { |
michael@0 | 454 | this._log.warn("Report failed. Server responded with HTTP " |
michael@0 | 455 | + request.response.status); |
michael@0 | 456 | } |
michael@0 | 457 | |
michael@0 | 458 | // Do not block on errors, we're done or aborted by now anyway. |
michael@0 | 459 | callback(); |
michael@0 | 460 | })); |
michael@0 | 461 | }, |
michael@0 | 462 | |
michael@0 | 463 | _computeStepOne: function _computeStepOne(callback) { |
michael@0 | 464 | this._log.trace("Computing round 1."); |
michael@0 | 465 | let gx1 = {}; |
michael@0 | 466 | let gv1 = {}; |
michael@0 | 467 | let r1 = {}; |
michael@0 | 468 | let gx2 = {}; |
michael@0 | 469 | let gv2 = {}; |
michael@0 | 470 | let r2 = {}; |
michael@0 | 471 | try { |
michael@0 | 472 | this._jpake.round1(this._my_signerid, gx1, gv1, r1, gx2, gv2, r2); |
michael@0 | 473 | } catch (ex) { |
michael@0 | 474 | this._log.error("JPAKE round 1 threw: " + ex); |
michael@0 | 475 | this.abort(JPAKE_ERROR_INTERNAL); |
michael@0 | 476 | return; |
michael@0 | 477 | } |
michael@0 | 478 | let one = {gx1: gx1.value, |
michael@0 | 479 | gx2: gx2.value, |
michael@0 | 480 | zkp_x1: {gr: gv1.value, b: r1.value, id: this._my_signerid}, |
michael@0 | 481 | zkp_x2: {gr: gv2.value, b: r2.value, id: this._my_signerid}}; |
michael@0 | 482 | this._outgoing = {type: this._my_signerid + "1", |
michael@0 | 483 | version: KEYEXCHANGE_VERSION, |
michael@0 | 484 | payload: one}; |
michael@0 | 485 | this._log.trace("Generated message " + this._outgoing.type); |
michael@0 | 486 | callback(); |
michael@0 | 487 | }, |
michael@0 | 488 | |
michael@0 | 489 | _computeStepTwo: function _computeStepTwo(callback) { |
michael@0 | 490 | this._log.trace("Computing round 2."); |
michael@0 | 491 | if (this._incoming.type != this._their_signerid + "1") { |
michael@0 | 492 | this._log.error("Invalid round 1 message: " |
michael@0 | 493 | + JSON.stringify(this._incoming)); |
michael@0 | 494 | this.abort(JPAKE_ERROR_WRONGMESSAGE); |
michael@0 | 495 | return; |
michael@0 | 496 | } |
michael@0 | 497 | |
michael@0 | 498 | let step1 = this._incoming.payload; |
michael@0 | 499 | if (!step1 || !step1.zkp_x1 || step1.zkp_x1.id != this._their_signerid |
michael@0 | 500 | || !step1.zkp_x2 || step1.zkp_x2.id != this._their_signerid) { |
michael@0 | 501 | this._log.error("Invalid round 1 payload: " + JSON.stringify(step1)); |
michael@0 | 502 | this.abort(JPAKE_ERROR_WRONGMESSAGE); |
michael@0 | 503 | return; |
michael@0 | 504 | } |
michael@0 | 505 | |
michael@0 | 506 | let A = {}; |
michael@0 | 507 | let gvA = {}; |
michael@0 | 508 | let rA = {}; |
michael@0 | 509 | |
michael@0 | 510 | try { |
michael@0 | 511 | this._jpake.round2(this._their_signerid, this._secret, |
michael@0 | 512 | step1.gx1, step1.zkp_x1.gr, step1.zkp_x1.b, |
michael@0 | 513 | step1.gx2, step1.zkp_x2.gr, step1.zkp_x2.b, |
michael@0 | 514 | A, gvA, rA); |
michael@0 | 515 | } catch (ex) { |
michael@0 | 516 | this._log.error("JPAKE round 2 threw: " + ex); |
michael@0 | 517 | this.abort(JPAKE_ERROR_INTERNAL); |
michael@0 | 518 | return; |
michael@0 | 519 | } |
michael@0 | 520 | let two = {A: A.value, |
michael@0 | 521 | zkp_A: {gr: gvA.value, b: rA.value, id: this._my_signerid}}; |
michael@0 | 522 | this._outgoing = {type: this._my_signerid + "2", |
michael@0 | 523 | version: KEYEXCHANGE_VERSION, |
michael@0 | 524 | payload: two}; |
michael@0 | 525 | this._log.trace("Generated message " + this._outgoing.type); |
michael@0 | 526 | callback(); |
michael@0 | 527 | }, |
michael@0 | 528 | |
michael@0 | 529 | _computeFinal: function _computeFinal(callback) { |
michael@0 | 530 | if (this._incoming.type != this._their_signerid + "2") { |
michael@0 | 531 | this._log.error("Invalid round 2 message: " |
michael@0 | 532 | + JSON.stringify(this._incoming)); |
michael@0 | 533 | this.abort(JPAKE_ERROR_WRONGMESSAGE); |
michael@0 | 534 | return; |
michael@0 | 535 | } |
michael@0 | 536 | |
michael@0 | 537 | let step2 = this._incoming.payload; |
michael@0 | 538 | if (!step2 || !step2.zkp_A || step2.zkp_A.id != this._their_signerid) { |
michael@0 | 539 | this._log.error("Invalid round 2 payload: " + JSON.stringify(step1)); |
michael@0 | 540 | this.abort(JPAKE_ERROR_WRONGMESSAGE); |
michael@0 | 541 | return; |
michael@0 | 542 | } |
michael@0 | 543 | |
michael@0 | 544 | let aes256Key = {}; |
michael@0 | 545 | let hmac256Key = {}; |
michael@0 | 546 | |
michael@0 | 547 | try { |
michael@0 | 548 | this._jpake.final(step2.A, step2.zkp_A.gr, step2.zkp_A.b, HMAC_INPUT, |
michael@0 | 549 | aes256Key, hmac256Key); |
michael@0 | 550 | } catch (ex) { |
michael@0 | 551 | this._log.error("JPAKE final round threw: " + ex); |
michael@0 | 552 | this.abort(JPAKE_ERROR_INTERNAL); |
michael@0 | 553 | return; |
michael@0 | 554 | } |
michael@0 | 555 | |
michael@0 | 556 | this._crypto_key = aes256Key.value; |
michael@0 | 557 | let hmac_key = Utils.makeHMACKey(Utils.safeAtoB(hmac256Key.value)); |
michael@0 | 558 | this._hmac_hasher = Utils.makeHMACHasher(Ci.nsICryptoHMAC.SHA256, hmac_key); |
michael@0 | 559 | |
michael@0 | 560 | callback(); |
michael@0 | 561 | }, |
michael@0 | 562 | |
michael@0 | 563 | _computeKeyVerification: function _computeKeyVerification(callback) { |
michael@0 | 564 | this._log.trace("Encrypting key verification value."); |
michael@0 | 565 | let iv, ciphertext; |
michael@0 | 566 | try { |
michael@0 | 567 | iv = Svc.Crypto.generateRandomIV(); |
michael@0 | 568 | ciphertext = Svc.Crypto.encrypt(JPAKE_VERIFY_VALUE, |
michael@0 | 569 | this._crypto_key, iv); |
michael@0 | 570 | } catch (ex) { |
michael@0 | 571 | this._log.error("Failed to encrypt key verification value."); |
michael@0 | 572 | this.abort(JPAKE_ERROR_INTERNAL); |
michael@0 | 573 | return; |
michael@0 | 574 | } |
michael@0 | 575 | this._outgoing = {type: this._my_signerid + "3", |
michael@0 | 576 | version: KEYEXCHANGE_VERSION, |
michael@0 | 577 | payload: {ciphertext: ciphertext, IV: iv}}; |
michael@0 | 578 | this._log.trace("Generated message " + this._outgoing.type); |
michael@0 | 579 | callback(); |
michael@0 | 580 | }, |
michael@0 | 581 | |
michael@0 | 582 | _verifyPairing: function _verifyPairing(callback) { |
michael@0 | 583 | this._log.trace("Verifying their key."); |
michael@0 | 584 | if (this._incoming.type != this._their_signerid + "3") { |
michael@0 | 585 | this._log.error("Invalid round 3 data: " + |
michael@0 | 586 | JSON.stringify(this._incoming)); |
michael@0 | 587 | this.abort(JPAKE_ERROR_WRONGMESSAGE); |
michael@0 | 588 | return; |
michael@0 | 589 | } |
michael@0 | 590 | let step3 = this._incoming.payload; |
michael@0 | 591 | let ciphertext; |
michael@0 | 592 | try { |
michael@0 | 593 | ciphertext = Svc.Crypto.encrypt(JPAKE_VERIFY_VALUE, |
michael@0 | 594 | this._crypto_key, step3.IV); |
michael@0 | 595 | if (ciphertext != step3.ciphertext) { |
michael@0 | 596 | throw "Key mismatch!"; |
michael@0 | 597 | } |
michael@0 | 598 | } catch (ex) { |
michael@0 | 599 | this._log.error("Keys don't match!"); |
michael@0 | 600 | this.abort(JPAKE_ERROR_KEYMISMATCH); |
michael@0 | 601 | return; |
michael@0 | 602 | } |
michael@0 | 603 | |
michael@0 | 604 | this._log.debug("Verified pairing!"); |
michael@0 | 605 | this._paired = true; |
michael@0 | 606 | Utils.nextTick(function () { this.controller.onPaired(); }, this); |
michael@0 | 607 | callback(); |
michael@0 | 608 | }, |
michael@0 | 609 | |
michael@0 | 610 | _encryptData: function _encryptData(callback) { |
michael@0 | 611 | this._log.trace("Encrypting data."); |
michael@0 | 612 | let iv, ciphertext, hmac; |
michael@0 | 613 | try { |
michael@0 | 614 | iv = Svc.Crypto.generateRandomIV(); |
michael@0 | 615 | ciphertext = Svc.Crypto.encrypt(this._data, this._crypto_key, iv); |
michael@0 | 616 | hmac = Utils.bytesAsHex(Utils.digestUTF8(ciphertext, this._hmac_hasher)); |
michael@0 | 617 | } catch (ex) { |
michael@0 | 618 | this._log.error("Failed to encrypt data."); |
michael@0 | 619 | this.abort(JPAKE_ERROR_INTERNAL); |
michael@0 | 620 | return; |
michael@0 | 621 | } |
michael@0 | 622 | this._outgoing = {type: this._my_signerid + "3", |
michael@0 | 623 | version: KEYEXCHANGE_VERSION, |
michael@0 | 624 | payload: {ciphertext: ciphertext, IV: iv, hmac: hmac}}; |
michael@0 | 625 | this._log.trace("Generated message " + this._outgoing.type); |
michael@0 | 626 | callback(); |
michael@0 | 627 | }, |
michael@0 | 628 | |
michael@0 | 629 | _decryptData: function _decryptData(callback) { |
michael@0 | 630 | this._log.trace("Verifying their key."); |
michael@0 | 631 | if (this._incoming.type != this._their_signerid + "3") { |
michael@0 | 632 | this._log.error("Invalid round 3 data: " |
michael@0 | 633 | + JSON.stringify(this._incoming)); |
michael@0 | 634 | this.abort(JPAKE_ERROR_WRONGMESSAGE); |
michael@0 | 635 | return; |
michael@0 | 636 | } |
michael@0 | 637 | let step3 = this._incoming.payload; |
michael@0 | 638 | try { |
michael@0 | 639 | let hmac = Utils.bytesAsHex( |
michael@0 | 640 | Utils.digestUTF8(step3.ciphertext, this._hmac_hasher)); |
michael@0 | 641 | if (hmac != step3.hmac) { |
michael@0 | 642 | throw "HMAC validation failed!"; |
michael@0 | 643 | } |
michael@0 | 644 | } catch (ex) { |
michael@0 | 645 | this._log.error("HMAC validation failed."); |
michael@0 | 646 | this.abort(JPAKE_ERROR_KEYMISMATCH); |
michael@0 | 647 | return; |
michael@0 | 648 | } |
michael@0 | 649 | |
michael@0 | 650 | this._log.trace("Decrypting data."); |
michael@0 | 651 | let cleartext; |
michael@0 | 652 | try { |
michael@0 | 653 | cleartext = Svc.Crypto.decrypt(step3.ciphertext, this._crypto_key, |
michael@0 | 654 | step3.IV); |
michael@0 | 655 | } catch (ex) { |
michael@0 | 656 | this._log.error("Failed to decrypt data."); |
michael@0 | 657 | this.abort(JPAKE_ERROR_INTERNAL); |
michael@0 | 658 | return; |
michael@0 | 659 | } |
michael@0 | 660 | |
michael@0 | 661 | try { |
michael@0 | 662 | this._newData = JSON.parse(cleartext); |
michael@0 | 663 | } catch (ex) { |
michael@0 | 664 | this._log.error("Invalid data data: " + JSON.stringify(cleartext)); |
michael@0 | 665 | this.abort(JPAKE_ERROR_INVALID); |
michael@0 | 666 | return; |
michael@0 | 667 | } |
michael@0 | 668 | |
michael@0 | 669 | this._log.trace("Decrypted data."); |
michael@0 | 670 | callback(); |
michael@0 | 671 | }, |
michael@0 | 672 | |
michael@0 | 673 | _complete: function _complete() { |
michael@0 | 674 | this._log.debug("Exchange completed."); |
michael@0 | 675 | this._finished = true; |
michael@0 | 676 | Utils.nextTick(function () { this.controller.onComplete(this._newData); }, |
michael@0 | 677 | this); |
michael@0 | 678 | } |
michael@0 | 679 | |
michael@0 | 680 | }; |
michael@0 | 681 | |
michael@0 | 682 | |
michael@0 | 683 | /** |
michael@0 | 684 | * Send credentials over an active J-PAKE channel. |
michael@0 | 685 | * |
michael@0 | 686 | * This object is designed to take over as the JPAKEClient controller, |
michael@0 | 687 | * presumably replacing one that is UI-based which would either cause |
michael@0 | 688 | * DOM objects to leak or the JPAKEClient to be GC'ed when the DOM |
michael@0 | 689 | * context disappears. This object stays alive for the duration of the |
michael@0 | 690 | * transfer by being strong-ref'ed as an nsIObserver. |
michael@0 | 691 | * |
michael@0 | 692 | * Credentials are sent after the first sync has been completed |
michael@0 | 693 | * (successfully or not.) |
michael@0 | 694 | * |
michael@0 | 695 | * Usage: |
michael@0 | 696 | * |
michael@0 | 697 | * jpakeclient.controller = new SendCredentialsController(jpakeclient, |
michael@0 | 698 | * service); |
michael@0 | 699 | * |
michael@0 | 700 | */ |
michael@0 | 701 | this.SendCredentialsController = |
michael@0 | 702 | function SendCredentialsController(jpakeclient, service) { |
michael@0 | 703 | this._log = Log.repository.getLogger("Sync.SendCredentialsController"); |
michael@0 | 704 | this._log.level = Log.Level[Svc.Prefs.get("log.logger.service.main")]; |
michael@0 | 705 | |
michael@0 | 706 | this._log.trace("Loading."); |
michael@0 | 707 | this.jpakeclient = jpakeclient; |
michael@0 | 708 | this.service = service; |
michael@0 | 709 | |
michael@0 | 710 | // Register ourselves as observers the first Sync finishing (either |
michael@0 | 711 | // successfully or unsuccessfully, we don't care) or for removing |
michael@0 | 712 | // this device's sync configuration, in case that happens while we |
michael@0 | 713 | // haven't finished the first sync yet. |
michael@0 | 714 | Services.obs.addObserver(this, "weave:service:sync:finish", false); |
michael@0 | 715 | Services.obs.addObserver(this, "weave:service:sync:error", false); |
michael@0 | 716 | Services.obs.addObserver(this, "weave:service:start-over", false); |
michael@0 | 717 | } |
michael@0 | 718 | SendCredentialsController.prototype = { |
michael@0 | 719 | |
michael@0 | 720 | unload: function unload() { |
michael@0 | 721 | this._log.trace("Unloading."); |
michael@0 | 722 | try { |
michael@0 | 723 | Services.obs.removeObserver(this, "weave:service:sync:finish"); |
michael@0 | 724 | Services.obs.removeObserver(this, "weave:service:sync:error"); |
michael@0 | 725 | Services.obs.removeObserver(this, "weave:service:start-over"); |
michael@0 | 726 | } catch (ex) { |
michael@0 | 727 | // Ignore. |
michael@0 | 728 | } |
michael@0 | 729 | }, |
michael@0 | 730 | |
michael@0 | 731 | observe: function observe(subject, topic, data) { |
michael@0 | 732 | switch (topic) { |
michael@0 | 733 | case "weave:service:sync:finish": |
michael@0 | 734 | case "weave:service:sync:error": |
michael@0 | 735 | Utils.nextTick(this.sendCredentials, this); |
michael@0 | 736 | break; |
michael@0 | 737 | case "weave:service:start-over": |
michael@0 | 738 | // This will call onAbort which will call unload(). |
michael@0 | 739 | this.jpakeclient.abort(); |
michael@0 | 740 | break; |
michael@0 | 741 | } |
michael@0 | 742 | }, |
michael@0 | 743 | |
michael@0 | 744 | sendCredentials: function sendCredentials() { |
michael@0 | 745 | this._log.trace("Sending credentials."); |
michael@0 | 746 | let credentials = {account: this.service.identity.account, |
michael@0 | 747 | password: this.service.identity.basicPassword, |
michael@0 | 748 | synckey: this.service.identity.syncKey, |
michael@0 | 749 | serverURL: this.service.serverURL}; |
michael@0 | 750 | this.jpakeclient.sendAndComplete(credentials); |
michael@0 | 751 | }, |
michael@0 | 752 | |
michael@0 | 753 | // JPAKEClient controller API |
michael@0 | 754 | |
michael@0 | 755 | onComplete: function onComplete() { |
michael@0 | 756 | this._log.debug("Exchange was completed successfully!"); |
michael@0 | 757 | this.unload(); |
michael@0 | 758 | |
michael@0 | 759 | // Schedule a Sync for soonish to fetch the data uploaded by the |
michael@0 | 760 | // device with which we just paired. |
michael@0 | 761 | this.service.scheduler.scheduleNextSync(this.service.scheduler.activeInterval); |
michael@0 | 762 | }, |
michael@0 | 763 | |
michael@0 | 764 | onAbort: function onAbort(error) { |
michael@0 | 765 | // It doesn't really matter why we aborted, but the channel is closed |
michael@0 | 766 | // for sure, so we won't be able to do anything with it. |
michael@0 | 767 | this._log.debug("Exchange was aborted with error: " + error); |
michael@0 | 768 | this.unload(); |
michael@0 | 769 | }, |
michael@0 | 770 | |
michael@0 | 771 | // Irrelevant methods for this controller: |
michael@0 | 772 | displayPIN: function displayPIN() {}, |
michael@0 | 773 | onPairingStart: function onPairingStart() {}, |
michael@0 | 774 | onPaired: function onPaired() {}, |
michael@0 | 775 | }; |