Bug 1141133 - Implement encrypt/decrypt of context information ready for Loop's context in conversation work. r=mikedeboer
authorMark Banner <standard8@mozilla.com>
Fri, 20 Mar 2015 14:30:49 +0000
changeset 251862 c0262092fed258760ba2c55975c0cac6020fa408
parent 251861 e807296e0ae23c53f2600ae497b12df02a571edd
child 251863 c4915d92b0c5363303e285da04f0b92945223c11
push id1156
push userpbrosset@mozilla.com
push dateFri, 20 Mar 2015 16:00:24 +0000
reviewersmikedeboer
bugs1141133
milestone39.0a1
Bug 1141133 - Implement encrypt/decrypt of context information ready for Loop's context in conversation work. r=mikedeboer
browser/components/loop/content/shared/js/crypto.js
browser/components/loop/content/shared/js/utils.js
browser/components/loop/test/shared/crypto_test.js
browser/components/loop/test/shared/index.html
browser/components/loop/test/shared/utils_test.js
browser/components/loop/test/shared/vendor/chai-as-promised-4.3.0.js
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/js/crypto.js
@@ -0,0 +1,238 @@
+/* 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/. */
+
+/* global loop:true */
+
+var loop = loop || {};
+
+loop.crypto = (function() {
+  "use strict";
+
+  var ALGORITHM = "AES-GCM";
+  var KEY_LENGTH = 128;
+  // We use JSON web key formats for the generated keys.
+  // https://tools.ietf.org/html/draft-ietf-jose-json-web-key-41
+  var KEY_FORMAT = "jwk";
+  // This is the JSON web key type from the generateKey algorithm.
+  var KEY_TYPE = "oct";
+  var ENCRYPT_TAG_LENGTH = 128;
+  var INITIALIZATION_VECTOR_LENGTH = 12;
+
+  var sharedUtils = loop.shared.utils;
+
+  /**
+   * Root object, by default set to window.
+   * @type {DOMWindow|Object}
+   */
+  var rootObject = window;
+
+  /**
+   * Sets a new root object.  This is useful for testing crypto not supported as
+   * it allows us to fake crypto not being present.
+   * In beforeEach(), loop.crypto.setRootObject is used to
+   * substitute a fake window, and in afterEach(), the real window object is
+   * replaced.
+   *
+   * @param {Object}
+   */
+  function setRootObject(obj) {
+    console.log("loop.crpyto.mixins: rootObject set to " + obj);
+    rootObject = obj;
+  }
+
+  /**
+   * Determines if Web Crypto is supported by this browser.
+   *
+   * @return {Boolean} True if Web Crypto is supported
+   */
+  function isSupported() {
+    return "crypto" in rootObject;
+  }
+
+  /**
+   * Generates a random key using the Web Crypto libraries.
+   *
+   * @return {Promise} A promise which is rejected on failure, or resolved
+   *                   with a string that is in the JSON web key format.
+   */
+  function generateKey() {
+    if (!isSupported()) {
+      throw new Error("Web Crypto is not supported");
+    }
+
+    return new Promise(function(resolve, reject) {
+      // First get a crypto key.
+      rootObject.crypto.subtle.generateKey({name: ALGORITHM, length: KEY_LENGTH },
+        // `true` means that the key can be extracted from the CryptoKey object.
+        true,
+        // Usages for the key.
+        ["encrypt", "decrypt"]
+      ).then(function(cryptoKey) {
+        // Now extract the key in the JSON web key format.
+        return rootObject.crypto.subtle.exportKey(KEY_FORMAT, cryptoKey);
+      }).then(function(exportedKey) {
+        // Lastly resolve the promise with the new key.
+        resolve(exportedKey.k);
+      }).catch(function(error) {
+        reject(error);
+      });
+    });
+  }
+
+  /**
+   * Encrypts an object using the specified key.
+   *
+   * @param {String} key      The key to use for encryption. This should have
+   *                          been generated by generateKey.
+   * @param {String} data     The string to be encrypted.
+   *
+   * @return {Promise} A promise which is rejected on failure, or resolved
+   *                   with a string that is the encrypted context.
+   */
+  function encryptBytes(key, data) {
+    if (!isSupported()) {
+      throw new Error("Web Crypto is not supported");
+    }
+
+    var iv = new Uint8Array(INITIALIZATION_VECTOR_LENGTH);
+
+    return new Promise(function(resolve, reject) {
+      // First import the key to a format we can use.
+      rootObject.crypto.subtle.importKey(KEY_FORMAT,
+        {k: key, kty: KEY_TYPE},
+        ALGORITHM,
+        // If the key is extractable.
+        true,
+        // What we're using it for.
+        ["encrypt"]
+      ).then(function(cryptoKey) {
+        // Now we've got the cryptoKey, we can do the actual encryption.
+
+        // First get the data into the format we need.
+        var dataBuffer = sharedUtils.strToUint8Array(data);
+
+        // It is critically important to change the IV any time the
+        // encrypted information is updated.
+        rootObject.crypto.getRandomValues(iv);
+
+        return rootObject.crypto.subtle.encrypt({
+            name: ALGORITHM,
+            iv: iv,
+            tagLength: ENCRYPT_TAG_LENGTH
+          }, cryptoKey,
+          dataBuffer);
+      }).then(function(cipherText) {
+        // Join the initialization vector and context for returning.
+        var joinedData = _mergeIVandCipherText(iv, new DataView(cipherText));
+
+        // Now convert to a string and base-64 encode.
+        var encryptedData = loop.shared.utils.btoa(joinedData);
+
+        resolve(encryptedData);
+      }).catch(function(error) {
+        reject(error);
+      });
+    });
+  }
+
+  /**
+   * Decrypts an object using the specified key.
+   *
+   * @param {String} key           The key to use for encryption. This should have
+   *                               been generated by generateKey.
+   * @param {String} encryptedData The encrypted context.
+   * @return {Promise} A promise which is rejected on failure, or resolved
+   *                   with a string that is the decrypted context.
+   */
+  function decryptBytes(key, encryptedData) {
+    if (!isSupported()) {
+      throw new Error("Web Crypto is not supported");
+    }
+
+    return new Promise(function(resolve, reject) {
+      // First import the key to a format we can use.
+      rootObject.crypto.subtle.importKey(KEY_FORMAT,
+        {k: key, kty: KEY_TYPE},
+        ALGORITHM,
+        // If the key is extractable.
+        true,
+        // What we're using it for.
+        ["decrypt"]
+      ).then(function(cryptoKey) {
+        // Now we've got the key, start the decryption.
+        var splitData = _splitIVandCipherText(encryptedData);
+
+        return rootObject.crypto.subtle.decrypt({
+          name: ALGORITHM,
+          iv: splitData.iv,
+          tagLength: ENCRYPT_TAG_LENGTH
+        }, cryptoKey, splitData.cipherText);
+      }).then(function(plainText) {
+        // Now we just turn it back into a string and then an object.
+        resolve(sharedUtils.Uint8ArrayToStr(new Uint8Array(plainText)));
+      }).catch(function(error) {
+        reject(error);
+      });
+    });
+  }
+
+  /**
+   * Appends the cipher text to the end of the initialization vector and
+   * returns the result.
+   *
+   * @param {Uint8Array} ivArray The array of initialization vector values.
+   * @param {DataView} cipherTextDataView The cipherText in data view format.
+   * @return {Uint8Array} An array of the IV and cipherText.
+   */
+  function _mergeIVandCipherText(ivArray, cipherTextDataView) {
+    // First we translate the data view to an array so we can get
+    // the length.
+    var cipherText = new Uint8Array(cipherTextDataView.buffer);
+    var cipherTextLength = cipherText.length;
+
+    var joinedContext = new Uint8Array(INITIALIZATION_VECTOR_LENGTH + cipherTextLength);
+
+    var i;
+    for (i = 0; i < INITIALIZATION_VECTOR_LENGTH; i++) {
+      joinedContext[i] = ivArray[i];
+    }
+
+    for (i = 0; i < cipherTextLength; i++) {
+      joinedContext[i + INITIALIZATION_VECTOR_LENGTH] = cipherText[i];
+    }
+
+    return joinedContext;
+  }
+
+  /**
+   * Takes the IV from the start of the passed in array and separates
+   * out the cipher text.
+   *
+   * @param {String} encryptedData Encrypted data in base64 format.
+   * @return {Object} An object consisting of two items: iv and cipherText,
+   *                  both are Uint8Arrays.
+   */
+  function _splitIVandCipherText(encryptedData) {
+    // Convert into byte arrays.
+    var encryptedDataArray = loop.shared.utils.atob(encryptedData);
+
+    // Now split out the initialization vector and the cipherText.
+    var iv = encryptedDataArray.slice(0, INITIALIZATION_VECTOR_LENGTH);
+    var cipherText = encryptedDataArray.slice(INITIALIZATION_VECTOR_LENGTH,
+                                              encryptedDataArray.length);
+
+    return {
+      iv: iv,
+      cipherText: cipherText
+    };
+  }
+
+  return {
+    decryptBytes: decryptBytes,
+    encryptBytes: encryptBytes,
+    generateKey: generateKey,
+    isSupported: isSupported,
+    setRootObject: setRootObject
+  };
+})();
--- a/browser/components/loop/content/shared/js/utils.js
+++ b/browser/components/loop/content/shared/js/utils.js
@@ -163,26 +163,252 @@ loop.shared.utils = (function(mozL10n) {
         clientShortname2: mozL10n.get("clientShortname2"),
         clientSuperShortname: mozL10n.get("clientSuperShortname"),
         learnMoreUrl: navigator.mozLoop.getLoopPref("learnMoreUrl")
       }).replace(/\r\n/g, "\n").replace(/\n/g, "\r\n"),
       recipient
     );
   }
 
+  /**
+   * Binary-compatible Base64 decoding.
+   *
+   * Taken from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Base64_encoding_and_decoding
+   *
+   * @param {String} base64str The string to decode.
+   * @return {Uint8Array} The decoded result in array format.
+   */
+  function atob(base64str) {
+    var strippedEncoding = base64str.replace(/[^A-Za-z0-9\+\/]/g, "");
+    var inLength = strippedEncoding.length;
+    var outLength = inLength * 3 + 1 >> 2;
+    var result = new Uint8Array(outLength);
+
+    var mod3;
+    var mod4;
+    var uint24 = 0;
+    var outIndex = 0;
+
+    for (var inIndex = 0; inIndex < inLength; inIndex++) {
+      mod4 = inIndex & 3;
+      uint24 |= _b64ToUint6(strippedEncoding.charCodeAt(inIndex)) << 6 * (3 - mod4);
+
+      if (mod4 === 3 || inLength - inIndex === 1) {
+        for (mod3 = 0; mod3 < 3 && outIndex < outLength; mod3++, outIndex++) {
+          result[outIndex] = uint24 >>> (16 >>> mod3 & 24) & 255;
+        }
+        uint24 = 0;
+      }
+    }
+
+    return result;
+  }
+
+  /**
+   * Binary-compatible Base64 encoding.
+   *
+   * Taken from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Base64_encoding_and_decoding
+   *
+   * @param {Uint8Array} bytes The data to encode.
+   * @return {String} The base64 encoded string.
+   */
+  function btoa(bytes) {
+    var mod3 = 2;
+    var result = "";
+    var length = bytes.length;
+    var uint24 = 0;
+
+    for (var index = 0; index < length; index++) {
+      mod3 = index % 3;
+      if (index > 0 && (index * 4 / 3) % 76 === 0) {
+        result += "\r\n";
+      }
+      uint24 |= bytes[index] << (16 >>> mod3 & 24);
+      if (mod3 === 2 || length - index === 1) {
+        result += String.fromCharCode(_uint6ToB64(uint24 >>> 18 & 63),
+          _uint6ToB64(uint24 >>> 12 & 63),
+          _uint6ToB64(uint24 >>> 6 & 63),
+          _uint6ToB64(uint24 & 63));
+        uint24 = 0;
+      }
+    }
+
+    return result.substr(0, result.length - 2 + mod3) +
+      (mod3 === 2 ? "" : mod3 === 1 ? "=" : "==");
+  }
+
+  /**
+   * Utility function to decode a base64 character into an integer.
+   *
+   * Taken from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Base64_encoding_and_decoding
+   *
+   * @param {Number} chr The character code to decode.
+   * @return {Number} The decoded value.
+   */
+  function _b64ToUint6 (chr) {
+    return chr > 64 && chr < 91  ? chr - 65 :
+           chr > 96 && chr < 123 ? chr - 71 :
+           chr > 47 && chr < 58  ? chr + 4  :
+           chr === 43            ? 62       :
+           chr === 47            ? 63       : 0;
+  }
+
+  /**
+   * Utility function to encode an integer into a base64 character code.
+   *
+   * Taken from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Base64_encoding_and_decoding
+   *
+   * @param {Number} uint6 The number to encode.
+   * @return {Number} The encoded value.
+   */
+  function _uint6ToB64 (uint6) {
+    return uint6 < 26   ? uint6 + 65 :
+           uint6 < 52   ? uint6 + 71 :
+           uint6 < 62   ? uint6 - 4  :
+           uint6 === 62 ? 43         :
+           uint6 === 63 ? 47         : 65;
+  }
+
+  /**
+   * Utility function to convert a string into a uint8 array.
+   *
+   * Taken from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Base64_encoding_and_decoding
+   *
+   * @param {String} inString The string to convert.
+   * @return {Uint8Array} The converted string in array format.
+   */
+  function strToUint8Array(inString) {
+    var inLength = inString.length;
+    var arrayLength = 0;
+    var chr;
+
+    // Mapping.
+    for (var mapIndex = 0; mapIndex < inLength; mapIndex++) {
+      chr = inString.charCodeAt(mapIndex);
+      arrayLength += chr < 0x80      ? 1 :
+                     chr < 0x800     ? 2 :
+                     chr < 0x10000   ? 3 :
+                     chr < 0x200000  ? 4 :
+                     chr < 0x4000000 ? 5 : 6;
+    }
+
+    var result = new Uint8Array(arrayLength);
+    var index = 0;
+
+    // Transcription.
+    for (var chrIndex = 0; index < arrayLength; chrIndex++) {
+      chr = inString.charCodeAt(chrIndex);
+      if (chr < 128) {
+        // One byte.
+        result[index++] = chr;
+      } else if (chr < 0x800) {
+        // Two bytes.
+        result[index++] = 192 + (chr >>> 6);
+        result[index++] = 128 + (chr & 63);
+      } else if (chr < 0x10000) {
+        // Three bytes.
+        result[index++] = 224 + (chr >>> 12);
+        result[index++] = 128 + (chr >>> 6 & 63);
+        result[index++] = 128 + (chr & 63);
+      } else if (chr < 0x200000) {
+        // Four bytes.
+        result[index++] = 240 + (chr >>> 18);
+        result[index++] = 128 + (chr >>> 12 & 63);
+        result[index++] = 128 + (chr >>> 6 & 63);
+        result[index++] = 128 + (chr & 63);
+      } else if (chr < 0x4000000) {
+        // Five bytes.
+        result[index++] = 248 + (chr >>> 24);
+        result[index++] = 128 + (chr >>> 18 & 63);
+        result[index++] = 128 + (chr >>> 12 & 63);
+        result[index++] = 128 + (chr >>> 6 & 63);
+        result[index++] = 128 + (chr & 63);
+      } else { // if (chr <= 0x7fffffff)
+        // Six bytes.
+        result[index++] = 252 + (chr >>> 30);
+        result[index++] = 128 + (chr >>> 24 & 63);
+        result[index++] = 128 + (chr >>> 18 & 63);
+        result[index++] = 128 + (chr >>> 12 & 63);
+        result[index++] = 128 + (chr >>> 6 & 63);
+        result[index++] = 128 + (chr & 63);
+      }
+    }
+
+    return result;
+  }
+
+  /**
+   * Utility function to change a uint8 based integer array to a string.
+   *
+   * Taken from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Base64_encoding_and_decoding
+   *
+   * @param {Uint8Array} arrayBytes Array to convert.
+   * @param {String} The array as a string.
+   */
+  function Uint8ArrayToStr(arrayBytes) {
+    var result = "";
+    var length = arrayBytes.length;
+    var part;
+
+    for (var index = 0; index < length; index++) {
+      part = arrayBytes[index];
+      result += String.fromCharCode(
+        part > 251 && part < 254 && index + 5 < length ?
+          // Six bytes.
+          // (part - 252 << 30) may be not so safe in ECMAScript! So...:
+          (part - 252) * 1073741824 +
+          (arrayBytes[++index] - 128 << 24) +
+          (arrayBytes[++index] - 128 << 18) +
+          (arrayBytes[++index] - 128 << 12) +
+          (arrayBytes[++index] - 128 << 6) +
+           arrayBytes[++index] - 128 :
+        part > 247 && part < 252 && index + 4 < length ?
+          // Five bytes.
+          (part - 248 << 24) +
+          (arrayBytes[++index] - 128 << 18) +
+          (arrayBytes[++index] - 128 << 12) +
+          (arrayBytes[++index] - 128 << 6) +
+           arrayBytes[++index] - 128 :
+        part > 239 && part < 248 && index + 3 < length ?
+          // Four bytes.
+          (part - 240 << 18) +
+          (arrayBytes[++index] - 128 << 12) +
+          (arrayBytes[++index] - 128 << 6) +
+           arrayBytes[++index] - 128 :
+        part > 223 && part < 240 && index + 2 < length ?
+          // Three bytes.
+          (part - 224 << 12) +
+          (arrayBytes[++index] - 128 << 6) +
+           arrayBytes[++index] - 128 :
+        part > 191 && part < 224 && index + 1 < length ?
+          // Two bytes.
+          (part - 192 << 6) +
+           arrayBytes[++index] - 128 :
+          // One byte.
+          part
+      );
+    }
+
+    return result;
+  }
+
   return {
     CALL_TYPES: CALL_TYPES,
     FAILURE_DETAILS: FAILURE_DETAILS,
     REST_ERRNOS: REST_ERRNOS,
     WEBSOCKET_REASONS: WEBSOCKET_REASONS,
     STREAM_PROPERTIES: STREAM_PROPERTIES,
     SCREEN_SHARE_STATES: SCREEN_SHARE_STATES,
     composeCallUrlEmail: composeCallUrlEmail,
     formatDate: formatDate,
     getBoolPreference: getBoolPreference,
     isChrome: isChrome,
     isFirefox: isFirefox,
     isFirefoxOS: isFirefoxOS,
     isOpera: isOpera,
     getUnsupportedPlatform: getUnsupportedPlatform,
-    locationData: locationData
+    locationData: locationData,
+    atob: atob,
+    btoa: btoa,
+    strToUint8Array: strToUint8Array,
+    Uint8ArrayToStr: Uint8ArrayToStr
   };
 })(document.mozL10n || navigator.mozL10n);
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/shared/crypto_test.js
@@ -0,0 +1,113 @@
+/* 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/. */
+
+/* global loop, sinon */
+
+var expect = chai.expect;
+
+describe("loop.crypto", function() {
+  "use strict";
+
+  var sandbox, oldCrypto;
+
+  beforeEach(function() {
+    sandbox = sinon.sandbox.create();
+  });
+
+  afterEach(function() {
+    sandbox.restore();
+    loop.crypto.setRootObject(window);
+  });
+
+  describe("#isSupported", function() {
+    it("should return true by default", function() {
+      expect(loop.crypto.isSupported()).eql(true);
+    });
+
+    it("should return false if crypto isn't supported", function() {
+      loop.crypto.setRootObject({});
+
+      expect(loop.crypto.isSupported()).eql(false);
+    });
+  });
+
+  describe("#generateKey", function() {
+    it("should throw if web crypto is not available", function() {
+      loop.crypto.setRootObject({});
+
+      expect(function() {
+        loop.crypto.generateKey();
+      }).to.Throw(/not supported/);
+    });
+
+    it("should generate a key", function() {
+      // The key is a random string, so we can't really test much else.
+      return expect(loop.crypto.generateKey()).to.eventually.be.a("string");
+    });
+  });
+
+  describe("#encryptBytes", function() {
+    it("should throw if web crypto is not available", function() {
+      loop.crypto.setRootObject({});
+
+      expect(function() {
+        loop.crypto.encryptBytes();
+      }).to.Throw(/not supported/);
+    });
+
+    it("should encrypt an object with a specific key", function() {
+      return expect(loop.crypto.encryptBytes("Wt2-bZKeHO2wnaq00ZM6Nw",
+        JSON.stringify({test: true}))).to.eventually.be.a("string");
+    });
+  });
+
+  describe("#decryptBytes", function() {
+    it("should throw if web crypto is not available", function() {
+      loop.crypto.setRootObject({});
+
+      expect(function() {
+        loop.crypto.decryptBytes();
+      }).to.Throw(/not supported/);
+    });
+
+    it("should decypt an object via a specific key", function() {
+      var key = "Wt2-bZKeHO2wnaq00ZM6Nw";
+      var encryptedContext = "XvN9FDEm/GtE/5Bx5ezpn7JVDeZrtwOJy2CBjTGgJ4L33HhHOqEW+5k=";
+
+      return expect(loop.crypto.decryptBytes(key, encryptedContext)).to.eventually.eql(JSON.stringify({test: true}));
+    });
+
+    it("should fail if the key didn't work", function() {
+      var bad = "Bad-bZKeHO2wnaq00ZM6Nw";
+      var encryptedContext = "TGZaAE3mqsBFK0GfheZXXDCaRKXJmIKJ8WzF0KBEl4Aldzf3iYlAsLQdA8XSXXvtJR2UYz+f";
+
+      return expect(loop.crypto.decryptBytes(bad, encryptedContext)).to.be.rejected;
+    });
+  });
+
+  describe("Full cycle", function() {
+    it("should be able to encrypt and decypt in a full cycle", function(done) {
+      var context = JSON.stringify({
+        contextObject: true,
+        UTF8String: "对话"
+      });
+
+      return loop.crypto.generateKey().then(function (key) {
+        loop.crypto.encryptBytes(key, context).then(function(encryptedContext) {
+          loop.crypto.decryptBytes(key, encryptedContext).then(function(decryptedContext) {
+            expect(decryptedContext).eql(context);
+            done();
+          }).catch(function(error) {
+            done(error);
+          });
+        }).catch(function(error) {
+          done(error);
+        });
+      }).catch(function(error) {
+        done(error);
+      });
+    });
+  });
+
+});
--- a/browser/components/loop/test/shared/index.html
+++ b/browser/components/loop/test/shared/index.html
@@ -26,27 +26,29 @@
   <script src="../../content/shared/libs/jquery-2.1.0.js"></script>
   <script src="../../content/shared/libs/lodash-2.4.1.js"></script>
   <script src="../../content/shared/libs/backbone-1.1.2.js"></script>
   <script src="../../standalone/content/libs/l10n-gaia-02ca67948fe8.js"></script>
 
   <!-- test dependencies -->
   <script src="vendor/mocha-2.2.1.js"></script>
   <script src="vendor/chai-2.1.0.js"></script>
+  <script src="vendor/chai-as-promised-4.3.0.js"></script>
   <script src="vendor/sinon-1.13.0.js"></script>
   <script>
     /*global chai, mocha */
     chai.config.includeStack = true;
     mocha.setup('bdd');
   </script>
 
   <!-- App scripts -->
   <script src="../../content/shared/js/utils.js"></script>
   <script src="../../content/shared/js/models.js"></script>
   <script src="../../content/shared/js/mixins.js"></script>
+  <script src="../../content/shared/js/crypto.js"></script>
   <script src="../../content/shared/js/websocket.js"></script>
   <script src="../../content/shared/js/feedbackApiClient.js"></script>
   <script src="../../content/shared/js/validate.js"></script>
   <script src="../../content/shared/js/actions.js"></script>
   <script src="../../content/shared/js/dispatcher.js"></script>
   <script src="../../content/shared/js/otSdkDriver.js"></script>
   <script src="../../content/shared/js/store.js"></script>
   <script src="../../content/shared/js/roomStates.js"></script>
@@ -57,16 +59,17 @@
   <script src="../../content/shared/js/feedbackStore.js"></script>
   <script src="../../content/shared/js/views.js"></script>
   <script src="../../content/shared/js/feedbackViews.js"></script>
 
   <!-- Test scripts -->
   <script src="models_test.js"></script>
   <script src="mixins_test.js"></script>
   <script src="utils_test.js"></script>
+  <script src="crypto_test.js"></script>
   <script src="views_test.js"></script>
   <script src="websocket_test.js"></script>
   <script src="feedbackApiClient_test.js"></script>
   <script src="feedbackViews_test.js"></script>
   <script src="validate_test.js"></script>
   <script src="dispatcher_test.js"></script>
   <script src="activeRoomStore_test.js"></script>
   <script src="fxOSActiveRoomStore_test.js"></script>
--- a/browser/components/loop/test/shared/utils_test.js
+++ b/browser/components/loop/test/shared/utils_test.js
@@ -166,9 +166,56 @@ describe("loop.shared.utils", function()
     it("should compose a call url email", function() {
       sharedUtils.composeCallUrlEmail("http://invalid", "fake@invalid.tld");
 
       sinon.assert.calledOnce(composeEmail);
       sinon.assert.calledWith(composeEmail,
                               "subject", "body", "fake@invalid.tld");
     });
   });
+
+  describe("#btoa", function() {
+    it("should encode a basic base64 string", function() {
+      var result = sharedUtils.btoa(sharedUtils.strToUint8Array("crypto is great"));
+
+      expect(result).eql("Y3J5cHRvIGlzIGdyZWF0");
+    });
+
+    it("should pad encoded base64 strings", function() {
+      var result = sharedUtils.btoa(sharedUtils.strToUint8Array("crypto is grea"));
+
+      expect(result).eql("Y3J5cHRvIGlzIGdyZWE=");
+
+      result = sharedUtils.btoa(sharedUtils.strToUint8Array("crypto is gre"));
+
+      expect(result).eql("Y3J5cHRvIGlzIGdyZQ==");
+    });
+
+    it("should encode a non-unicode base64 string", function() {
+      var result = sharedUtils.btoa(sharedUtils.strToUint8Array("\uFDFD"));
+      expect(result).eql("77e9");
+    });
+  });
+
+  describe("#atob", function() {
+    it("should decode a basic base64 string", function() {
+      var result = sharedUtils.Uint8ArrayToStr(sharedUtils.atob("Y3J5cHRvIGlzIGdyZWF0"));
+
+      expect(result).eql("crypto is great");
+    });
+
+    it("should decode a padded base64 string", function() {
+      var result = sharedUtils.Uint8ArrayToStr(sharedUtils.atob("Y3J5cHRvIGlzIGdyZWE="));
+
+      expect(result).eql("crypto is grea");
+
+      result = sharedUtils.Uint8ArrayToStr(sharedUtils.atob("Y3J5cHRvIGlzIGdyZQ=="));
+
+      expect(result).eql("crypto is gre");
+    });
+
+    it("should decode a base64 string that has unicode characters", function() {
+      var result = sharedUtils.Uint8ArrayToStr(sharedUtils.atob("77e9"));
+
+      expect(result).eql("\uFDFD");
+    });
+  });
 });
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/shared/vendor/chai-as-promised-4.3.0.js
@@ -0,0 +1,377 @@
+(function () {
+    "use strict";
+
+    // Module systems magic dance.
+
+    /* istanbul ignore else */
+    if (typeof require === "function" && typeof exports === "object" && typeof module === "object") {
+        // NodeJS
+        module.exports = chaiAsPromised;
+    } else if (typeof define === "function" && define.amd) {
+        // AMD
+        define(function () {
+            return chaiAsPromised;
+        });
+    } else {
+        /*global self: false */
+
+        // Other environment (usually <script> tag): plug in to global chai instance directly.
+        chai.use(chaiAsPromised);
+
+        // Expose as a property of the global object so that consumers can configure the `transferPromiseness` property.
+        self.chaiAsPromised = chaiAsPromised;
+    }
+
+    chaiAsPromised.transferPromiseness = function (assertion, promise) {
+        assertion.then = promise.then.bind(promise);
+    };
+
+    chaiAsPromised.transformAsserterArgs = function (values) {
+        return values;
+    };
+
+    function chaiAsPromised(chai, utils) {
+        var Assertion = chai.Assertion;
+        var assert = chai.assert;
+
+        function isJQueryPromise(thenable) {
+            return typeof thenable.always === "function" &&
+                   typeof thenable.done === "function" &&
+                   typeof thenable.fail === "function" &&
+                   typeof thenable.pipe === "function" &&
+                   typeof thenable.progress === "function" &&
+                   typeof thenable.state === "function";
+        }
+
+        function assertIsAboutPromise(assertion) {
+            if (typeof assertion._obj.then !== "function") {
+                throw new TypeError(utils.inspect(assertion._obj) + " is not a thenable.");
+            }
+            if (isJQueryPromise(assertion._obj)) {
+                throw new TypeError("Chai as Promised is incompatible with jQuery's thenables, sorry! Please use a " +
+                                    "Promises/A+ compatible library (see http://promisesaplus.com/).");
+            }
+        }
+
+        function method(name, asserter) {
+            utils.addMethod(Assertion.prototype, name, function () {
+                assertIsAboutPromise(this);
+                return asserter.apply(this, arguments);
+            });
+        }
+
+        function property(name, asserter) {
+            utils.addProperty(Assertion.prototype, name, function () {
+                assertIsAboutPromise(this);
+                return asserter.apply(this, arguments);
+            });
+        }
+
+        function doNotify(promise, done) {
+            promise.then(function () { done(); }, done);
+        }
+
+        // These are for clarity and to bypass Chai refusing to allow `undefined` as actual when used with `assert`.
+        function assertIfNegated(assertion, message, extra) {
+            assertion.assert(true, null, message, extra.expected, extra.actual);
+        }
+
+        function assertIfNotNegated(assertion, message, extra) {
+            assertion.assert(false, message, null, extra.expected, extra.actual);
+        }
+
+        function getBasePromise(assertion) {
+            // We need to chain subsequent asserters on top of ones in the chain already (consider
+            // `eventually.have.property("foo").that.equals("bar")`), only running them after the existing ones pass.
+            // So the first base-promise is `assertion._obj`, but after that we use the assertions themselves, i.e.
+            // previously derived promises, to chain off of.
+            return typeof assertion.then === "function" ? assertion : assertion._obj;
+        }
+
+        // Grab these first, before we modify `Assertion.prototype`.
+
+        var propertyNames = Object.getOwnPropertyNames(Assertion.prototype);
+
+        var propertyDescs = {};
+        propertyNames.forEach(function (name) {
+            propertyDescs[name] = Object.getOwnPropertyDescriptor(Assertion.prototype, name);
+        });
+
+        property("fulfilled", function () {
+            var that = this;
+            var derivedPromise = getBasePromise(that).then(
+                function (value) {
+                    that._obj = value;
+                    assertIfNegated(that,
+                                    "expected promise not to be fulfilled but it was fulfilled with #{act}",
+                                    { actual: value });
+                    return value;
+                },
+                function (reason) {
+                    assertIfNotNegated(that,
+                                       "expected promise to be fulfilled but it was rejected with #{act}",
+                                       { actual: reason });
+                }
+            );
+
+            chaiAsPromised.transferPromiseness(that, derivedPromise);
+        });
+
+        property("rejected", function () {
+            var that = this;
+            var derivedPromise = getBasePromise(that).then(
+                function (value) {
+                    that._obj = value;
+                    assertIfNotNegated(that,
+                                       "expected promise to be rejected but it was fulfilled with #{act}",
+                                       { actual: value });
+                    return value;
+                },
+                function (reason) {
+                    assertIfNegated(that,
+                                    "expected promise not to be rejected but it was rejected with #{act}",
+                                    { actual: reason });
+
+                    // Return the reason, transforming this into a fulfillment, to allow further assertions, e.g.
+                    // `promise.should.be.rejected.and.eventually.equal("reason")`.
+                    return reason;
+                }
+            );
+
+            chaiAsPromised.transferPromiseness(that, derivedPromise);
+        });
+
+        method("rejectedWith", function (Constructor, message) {
+            var desiredReason = null;
+            var constructorName = null;
+
+            if (Constructor instanceof RegExp || typeof Constructor === "string") {
+                message = Constructor;
+                Constructor = null;
+            } else if (Constructor && Constructor instanceof Error) {
+                desiredReason = Constructor;
+                Constructor = null;
+                message = null;
+            } else if (typeof Constructor === "function") {
+                constructorName = (new Constructor()).name;
+            } else {
+                Constructor = null;
+            }
+
+            var that = this;
+            var derivedPromise = getBasePromise(that).then(
+                function (value) {
+                    var assertionMessage = null;
+                    var expected = null;
+
+                    if (Constructor) {
+                        assertionMessage = "expected promise to be rejected with #{exp} but it was fulfilled with " +
+                                           "#{act}";
+                        expected = constructorName;
+                    } else if (message) {
+                        var verb = message instanceof RegExp ? "matching" : "including";
+                        assertionMessage = "expected promise to be rejected with an error " + verb + " #{exp} but it " +
+                                           "was fulfilled with #{act}";
+                        expected = message;
+                    } else if (desiredReason) {
+                        assertionMessage = "expected promise to be rejected with #{exp} but it was fulfilled with " +
+                                           "#{act}";
+                        expected = desiredReason;
+                    }
+
+                    that._obj = value;
+
+                    assertIfNotNegated(that, assertionMessage, { expected: expected, actual: value });
+                },
+                function (reason) {
+                    if (Constructor) {
+                        that.assert(reason instanceof Constructor,
+                                    "expected promise to be rejected with #{exp} but it was rejected with #{act}",
+                                    "expected promise not to be rejected with #{exp} but it was rejected with #{act}",
+                                    constructorName,
+                                    reason);
+                    }
+
+                    var reasonMessage = utils.type(reason) === "object" && "message" in reason ?
+                                            reason.message :
+                                            "" + reason;
+                    if (message && reasonMessage !== null && reasonMessage !== undefined) {
+                        if (message instanceof RegExp) {
+                            that.assert(message.test(reasonMessage),
+                                        "expected promise to be rejected with an error matching #{exp} but got #{act}",
+                                        "expected promise not to be rejected with an error matching #{exp}",
+                                        message,
+                                        reasonMessage);
+                        }
+                        if (typeof message === "string") {
+                            that.assert(reasonMessage.indexOf(message) !== -1,
+                                        "expected promise to be rejected with an error including #{exp} but got #{act}",
+                                        "expected promise not to be rejected with an error including #{exp}",
+                                        message,
+                                        reasonMessage);
+                        }
+                    }
+
+                    if (desiredReason) {
+                        that.assert(reason === desiredReason,
+                                    "expected promise to be rejected with #{exp} but it was rejected with #{act}",
+                                    "expected promise not to be rejected with #{exp}",
+                                    desiredReason,
+                                    reason);
+                    }
+                }
+            );
+
+            chaiAsPromised.transferPromiseness(that, derivedPromise);
+        });
+
+        property("eventually", function () {
+            utils.flag(this, "eventually", true);
+        });
+
+        method("notify", function (done) {
+            doNotify(getBasePromise(this), done);
+        });
+
+        method("become", function (value) {
+            return this.eventually.deep.equal(value);
+        });
+
+        ////////
+        // `eventually`
+
+        // We need to be careful not to trigger any getters, thus `Object.getOwnPropertyDescriptor` usage.
+        var methodNames = propertyNames.filter(function (name) {
+            return name !== "assert" && typeof propertyDescs[name].value === "function";
+        });
+
+        methodNames.forEach(function (methodName) {
+            Assertion.overwriteMethod(methodName, function (originalMethod) {
+                return function () {
+                    doAsserterAsyncAndAddThen(originalMethod, this, arguments);
+                };
+            });
+        });
+
+        var getterNames = propertyNames.filter(function (name) {
+            return name !== "_obj" && typeof propertyDescs[name].get === "function";
+        });
+
+        getterNames.forEach(function (getterName) {
+            var propertyDesc = propertyDescs[getterName];
+
+            // Chainable methods are things like `an`, which can work both for `.should.be.an.instanceOf` and as
+            // `should.be.an("object")`. We need to handle those specially.
+            var isChainableMethod = false;
+            try {
+                isChainableMethod = typeof propertyDesc.get.call({}) === "function";
+            } catch (e) { }
+
+            if (isChainableMethod) {
+                Assertion.addChainableMethod(
+                    getterName,
+                    function () {
+                        var assertion = this;
+                        function originalMethod() {
+                            return propertyDesc.get.call(assertion).apply(assertion, arguments);
+                        }
+                        doAsserterAsyncAndAddThen(originalMethod, this, arguments);
+                    },
+                    function () {
+                        var originalGetter = propertyDesc.get;
+                        doAsserterAsyncAndAddThen(originalGetter, this);
+                    }
+                );
+            } else {
+                Assertion.overwriteProperty(getterName, function (originalGetter) {
+                    return function () {
+                        doAsserterAsyncAndAddThen(originalGetter, this);
+                    };
+                });
+            }
+        });
+
+        function doAsserterAsyncAndAddThen(asserter, assertion, args) {
+            // Since we're intercepting all methods/properties, we need to just pass through if they don't want
+            // `eventually`, or if we've already fulfilled the promise (see below).
+            if (!utils.flag(assertion, "eventually")) {
+                return asserter.apply(assertion, args);
+            }
+
+            var derivedPromise = getBasePromise(assertion).then(function (value) {
+                // Set up the environment for the asserter to actually run: `_obj` should be the fulfillment value, and
+                // now that we have the value, we're no longer in "eventually" mode, so we won't run any of this code,
+                // just the base Chai code that we get to via the short-circuit above.
+                assertion._obj = value;
+                utils.flag(assertion, "eventually", false);
+
+                return args ? chaiAsPromised.transformAsserterArgs(args) : args;
+            }).then(function (args) {
+                asserter.apply(assertion, args);
+
+                // Because asserters, for example `property`, can change the value of `_obj` (i.e. change the "object"
+                // flag), we need to communicate this value change to subsequent chained asserters. Since we build a
+                // promise chain paralleling the asserter chain, we can use it to communicate such changes.
+                return assertion._obj;
+            });
+
+            chaiAsPromised.transferPromiseness(assertion, derivedPromise);
+        }
+
+        ///////
+        // Now use the `Assertion` framework to build an `assert` interface.
+        var originalAssertMethods = Object.getOwnPropertyNames(assert).filter(function (propName) {
+            return typeof assert[propName] === "function";
+        });
+
+        assert.isFulfilled = function (promise, message) {
+            return (new Assertion(promise, message)).to.be.fulfilled;
+        };
+
+        assert.isRejected = function (promise, toTestAgainst, message) {
+            if (typeof toTestAgainst === "string") {
+                message = toTestAgainst;
+                toTestAgainst = undefined;
+            }
+
+            var assertion = (new Assertion(promise, message));
+            return toTestAgainst !== undefined ? assertion.to.be.rejectedWith(toTestAgainst) : assertion.to.be.rejected;
+        };
+
+        assert.becomes = function (promise, value, message) {
+            return assert.eventually.deepEqual(promise, value, message);
+        };
+
+        assert.doesNotBecome = function (promise, value, message) {
+            return assert.eventually.notDeepEqual(promise, value, message);
+        };
+
+        assert.eventually = {};
+        originalAssertMethods.forEach(function (assertMethodName) {
+            assert.eventually[assertMethodName] = function (promise) {
+                var otherArgs = Array.prototype.slice.call(arguments, 1);
+
+                var customRejectionHandler;
+                var message = arguments[assert[assertMethodName].length - 1];
+                if (typeof message === "string") {
+                    customRejectionHandler = function (reason) {
+                        throw new chai.AssertionError(message + "\n\nOriginal reason: " + utils.inspect(reason));
+                    };
+                }
+
+                var returnedPromise = promise.then(
+                    function (fulfillmentValue) {
+                        return assert[assertMethodName].apply(assert, [fulfillmentValue].concat(otherArgs));
+                    },
+                    customRejectionHandler
+                );
+
+                returnedPromise.notify = function (done) {
+                    doNotify(returnedPromise, done);
+                };
+
+                return returnedPromise;
+            };
+        });
+    }
+}());