Bug 1423714 - Add a module to decrypt Chrome/Chromium Login Data from macOS. r=Gijs a=lizzard
authorMatthew Noorenberghe <mozilla@noorenberghe.ca>
Wed, 18 Sep 2019 04:47:22 +0000
changeset 555345 6b26012e920beba837ccf21edc5785791fe3c148
parent 555344 882e32d712a008d2baf12147816dc473289e5ea4
child 555346 90e626862af5a346f01f34ec65ef8c4d963c8f98
push id2165
push userffxbld-merge
push dateMon, 14 Oct 2019 16:30:58 +0000
treeherdermozilla-release@0eae18af659f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersGijs, lizzard
bugs1423714
milestone70.0
Bug 1423714 - Add a module to decrypt Chrome/Chromium Login Data from macOS. r=Gijs a=lizzard Differential Revision: https://phabricator.services.mozilla.com/D45969
browser/components/migration/ChromeMacOSLoginCrypto.jsm
browser/components/migration/moz.build
new file mode 100644
--- /dev/null
+++ b/browser/components/migration/ChromeMacOSLoginCrypto.jsm
@@ -0,0 +1,184 @@
+/* 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/. */
+
+"use strict";
+
+/**
+ * Class to handle encryption and decryption of logins stored in Chrome/Chromium
+ * on macOS.
+ */
+
+var EXPORTED_SYMBOLS = ["ChromeMacOSLoginCrypto"];
+
+Cu.importGlobalProperties(["crypto"]);
+
+const { XPCOMUtils } = ChromeUtils.import(
+  "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+  this,
+  "gKeychainUtils",
+  "@mozilla.org/profile/migrator/keychainmigrationutils;1",
+  "nsIKeychainMigrationUtils"
+);
+
+const gTextEncoder = new TextEncoder();
+const gTextDecoder = new TextDecoder();
+
+/**
+ * From macOS' CommonCrypto/CommonCryptor.h
+ */
+const kCCBlockSizeAES128 = 16;
+
+/* Chromium constants */
+
+/**
+ * kSalt from Chromium.
+ * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=43&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0
+ */
+const SALT = "saltysalt";
+
+/**
+ * kDerivedKeySizeInBits from Chromium.
+ * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=46&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0
+ */
+const DERIVED_KEY_SIZE_BITS = 128;
+
+/**
+ * kEncryptionIterations from Chromium.
+ * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=49&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0
+ */
+const ITERATIONS = 1003;
+
+/**
+ * kEncryptionVersionPrefix from Chromium.
+ * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=61&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0
+ */
+const ENCRYPTION_VERSION_PREFIX = "v10";
+
+/**
+ * The initialization vector is 16 space characters (character code 32 in decimal).
+ * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=220&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0
+ */
+const IV = new Uint8Array(kCCBlockSizeAES128).fill(32);
+
+/**
+ * Instances of this class have a shape similar to OSCrypto so it can be dropped
+ * into code which uses that. This isn't implemented as OSCrypto_mac.js since
+ * it isn't calling into encryption functions provided by macOS but instead
+ * relies on OS encryption key storage in Keychain. The algorithms here are
+ * specific to what is needed for Chrome login storage on macOS.
+ */
+class ChromeMacOSLoginCrypto {
+  /**
+   * @param {string} serviceName of the Keychain Item to use to derive a key.
+   * @param {string} accountName of the Keychain Item to use to derive a key.
+   * @param {string?} [testingPassphrase = null] A string to use as the passphrase
+   *                  to derive a key for testing purposes rather than retrieving
+   *                  it from the macOS Keychain since we don't yet have a way to
+   *                  mock the Keychain auth dialog.
+   */
+  constructor(serviceName, accountName, testingPassphrase = null) {
+    // We still exercise the keychain migration utils code when using a
+    // `testingPassphrase` in order to get some test coverage for that
+    // component, even though it's expected to throw since a login item with the
+    // service name and account name usually won't be found.
+    let encKey = testingPassphrase;
+    try {
+      encKey = gKeychainUtils.getGenericPassword(serviceName, accountName);
+    } catch (ex) {
+      if (!testingPassphrase) {
+        throw ex;
+      }
+    }
+
+    this.ALGORITHM = "AES-CBC";
+
+    this._keyPromise = crypto.subtle
+      .importKey("raw", gTextEncoder.encode(encKey), "PBKDF2", false, [
+        "deriveKey",
+      ])
+      .then(key => {
+        return crypto.subtle.deriveKey(
+          {
+            name: "PBKDF2",
+            salt: gTextEncoder.encode(SALT),
+            iterations: ITERATIONS,
+            hash: "SHA-1",
+          },
+          key,
+          { name: this.ALGORITHM, length: DERIVED_KEY_SIZE_BITS },
+          false,
+          ["decrypt", "encrypt"]
+        );
+      })
+      .catch(Cu.reportError);
+  }
+
+  /**
+   * Convert an array containing only two bytes unsigned numbers to a string.
+   * @param {number[]} arr - the array that needs to be converted.
+   * @returns {string} the string representation of the array.
+   */
+  arrayToString(arr) {
+    let str = "";
+    for (let i = 0; i < arr.length; i++) {
+      str += String.fromCharCode(arr[i]);
+    }
+    return str;
+  }
+
+  stringToArray(binary_string) {
+    let len = binary_string.length;
+    let bytes = new Uint8Array(len);
+    for (var i = 0; i < len; i++) {
+      bytes[i] = binary_string.charCodeAt(i);
+    }
+    return bytes;
+  }
+
+  /**
+   * @param {string} ciphertext ciphertext prefixed by the encryption version
+   *                            (see ENCRYPTION_VERSION_PREFIX).
+   * @returns {string} plaintext password
+   */
+  async decryptData(ciphertext) {
+    if (!ciphertext.startsWith(ENCRYPTION_VERSION_PREFIX)) {
+      throw new Error("Unknown encryption version");
+    }
+    let key = await this._keyPromise;
+    if (!key) {
+      throw new Error("Cannot decrypt without a key");
+    }
+    let plaintext = await crypto.subtle.decrypt(
+      { name: this.ALGORITHM, iv: IV },
+      key,
+      this.stringToArray(ciphertext.substring(ENCRYPTION_VERSION_PREFIX.length))
+    );
+    return gTextDecoder.decode(plaintext);
+  }
+
+  /**
+   * @param {USVString} plaintext to encrypt
+   * @returns {string} encrypted string consisting of UTF-16 code units prefixed
+   *                   by the ENCRYPTION_VERSION_PREFIX.
+   */
+  async encryptData(plaintext) {
+    let key = await this._keyPromise;
+    if (!key) {
+      throw new Error("Cannot encrypt without a key");
+    }
+
+    let ciphertext = await crypto.subtle.encrypt(
+      { name: this.ALGORITHM, iv: IV },
+      key,
+      gTextEncoder.encode(plaintext)
+    );
+    return (
+      ENCRYPTION_VERSION_PREFIX +
+      String.fromCharCode(...new Uint8Array(ciphertext))
+    );
+  }
+}
--- a/browser/components/migration/moz.build
+++ b/browser/components/migration/moz.build
@@ -40,16 +40,17 @@ if CONFIG['OS_ARCH'] == 'WINNT':
         'MSMigrationUtils.jsm',
     ]
 
 if CONFIG['MOZ_WIDGET_TOOLKIT'] == 'cocoa':
     EXPORTS += [
         'nsKeychainMigrationUtils.h',
     ]
     EXTRA_JS_MODULES += [
+        'ChromeMacOSLoginCrypto.jsm',
         'SafariProfileMigrator.jsm',
     ]
     SOURCES += [
         'nsKeychainMigrationUtils.mm',
     ]
     XPIDL_SOURCES += [
         'nsIKeychainMigrationUtils.idl',
     ]