Bug 1461477 - Create a CreditCard.jsm to consolidate various credit card handling and validation. r=MattN
☠☠ backed out by 354491fabd5f ☠ ☠
authorJared Wein <jwein@mozilla.com>
Tue, 15 May 2018 12:41:35 -0400
changeset 469263 9b26de736798720c1b30eeddb6c85941dc32579c
parent 469262 ebe99842f5f8d543e5453ce78b1eae3641830b13
child 469264 330f40fac3e84b7d0d5720003bc690336300fe86
push id187
push userfmarier@mozilla.com
push dateMon, 04 Jun 2018 22:28:11 +0000
reviewersMattN
bugs1461477
milestone62.0a1
Bug 1461477 - Create a CreditCard.jsm to consolidate various credit card handling and validation. r=MattN MozReview-Commit-ID: 3tJdzU3hBvY
browser/components/payments/test/browser/head.js
browser/extensions/formautofill/FormAutofillParent.jsm
browser/extensions/formautofill/FormAutofillStorage.jsm
browser/extensions/formautofill/FormAutofillUtils.jsm
browser/extensions/formautofill/ProfileAutoCompleteResult.jsm
browser/extensions/formautofill/content/manageDialog.js
browser/extensions/formautofill/test/browser/browser_creditCard_doorhanger.js
browser/extensions/formautofill/test/browser/browser_manageCreditCardsDialog.js
browser/extensions/formautofill/test/browser/head.js
browser/extensions/formautofill/test/mochitest/formautofill_common.js
browser/extensions/formautofill/test/mochitest/test_basic_creditcard_autocomplete_form.html
browser/extensions/formautofill/test/mochitest/test_clear_form.html
browser/extensions/formautofill/test/mochitest/test_creditcard_autocomplete_off.html
toolkit/components/satchel/formSubmitListener.js
toolkit/modules/CreditCard.jsm
toolkit/modules/moz.build
toolkit/modules/sessionstore/FormData.jsm
toolkit/modules/tests/browser/browser.ini
toolkit/modules/tests/browser/browser_CreditCard.js
toolkit/modules/tests/xpcshell/test_CreditCard.js
toolkit/modules/tests/xpcshell/xpcshell.ini
--- a/browser/components/payments/test/browser/head.js
+++ b/browser/components/payments/test/browser/head.js
@@ -190,19 +190,17 @@ function checkPaymentAddressMatchesStora
  * Checks that a card from autofill storage matches a Payment Request MethodDetails response.
  * @param {MethodDetails} methodDetails
  * @param {object} card
  * @param {string} msg to describe the check
  */
 function checkPaymentMethodDetailsMatchesCard(methodDetails, card, msg) {
   info(msg);
   // The card expiry month should be a zero-padded two-digit string.
-  let cardExpiryMonth = card["cc-exp-month"] < 10 ?
-                       "0" + card["cc-exp-month"] :
-                       card["cc-exp-month"].toString();
+  let cardExpiryMonth = card["cc-exp-month"].toString().padStart(2, "0");
   is(methodDetails.cardholderName, card["cc-name"], "Check cardholderName");
   is(methodDetails.cardNumber, card["cc-number"], "Check cardNumber");
   is(methodDetails.expiryMonth, cardExpiryMonth, "Check expiryMonth");
   is(methodDetails.expiryYear, card["cc-exp-year"], "Check expiryYear");
 }
 
 /**
  * Create a PaymentRequest object with the given parameters, then
--- a/browser/extensions/formautofill/FormAutofillParent.jsm
+++ b/browser/extensions/formautofill/FormAutofillParent.jsm
@@ -33,16 +33,17 @@ var EXPORTED_SYMBOLS = ["formAutofillPar
 
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 
 ChromeUtils.import("resource://formautofill/FormAutofillUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm",
+  CreditCard: "resource://gre/modules/CreditCard.jsm",
   FormAutofillPreferences: "resource://formautofill/FormAutofillPreferences.jsm",
   FormAutofillDoorhanger: "resource://formautofill/FormAutofillDoorhanger.jsm",
   MasterPassword: "resource://formautofill/MasterPassword.jsm",
 });
 
 this.log = null;
 FormAutofillUtils.defineLazyLogGetter(this, EXPORTED_SYMBOLS[0]);
 
@@ -514,17 +515,22 @@ FormAutofillParent.prototype = {
     setUsedStatus(2);
 
     return async () => {
       // Suppress the pending doorhanger from showing up if user disabled credit card in previous doorhanger.
       if (!FormAutofillUtils.isAutofillCreditCardsEnabled) {
         return;
       }
 
-      const description = FormAutofillUtils.getCreditCardLabel(creditCard.record, false);
+      const card = new CreditCard({
+        number: creditCard.record["cc-number"] || creditCard.record["cc-number-decrypted"],
+        encryptedNumber: creditCard.record["cc-number-encrypted"],
+        name: creditCard.record["cc-name"],
+      });
+      const description = await card.getLabel();
       const state = await FormAutofillDoorhanger.show(target,
                                                       creditCard.guid ? "updateCreditCard" : "addCreditCard",
                                                       description);
       if (state == "cancel") {
         return;
       }
 
       if (state == "disable") {
--- a/browser/extensions/formautofill/FormAutofillStorage.jsm
+++ b/browser/extensions/formautofill/FormAutofillStorage.jsm
@@ -127,16 +127,18 @@
 this.EXPORTED_SYMBOLS = ["formAutofillStorage"];
 
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://gre/modules/osfile.jsm");
 
 ChromeUtils.import("resource://formautofill/FormAutofillUtils.jsm");
 
+ChromeUtils.defineModuleGetter(this, "CreditCard",
+                               "resource://gre/modules/CreditCard.jsm");
 ChromeUtils.defineModuleGetter(this, "JSONFile",
                                "resource://gre/modules/JSONFile.jsm");
 ChromeUtils.defineModuleGetter(this, "FormAutofillNameUtils",
                                "resource://formautofill/FormAutofillNameUtils.jsm");
 ChromeUtils.defineModuleGetter(this, "MasterPassword",
                                "resource://formautofill/MasterPassword.jsm");
 ChromeUtils.defineModuleGetter(this, "PhoneNumber",
                                "resource://formautofill/phonenumberutils/PhoneNumber.jsm");
@@ -1156,17 +1158,17 @@ class AutofillRecords {
     this._normalizeFields(record);
 
     for (let key in record) {
       if (!this.VALID_FIELDS.includes(key)) {
         throw new Error(`"${key}" is not a valid field.`);
       }
       if (typeof record[key] !== "string" &&
           typeof record[key] !== "number") {
-        throw new Error(`"${key}" contains invalid data type.`);
+        throw new Error(`"${key}" contains invalid data type: ${typeof record[key]}`);
       }
       if (!preserveEmptyFields && record[key] === "") {
         delete record[key];
       }
     }
 
     if (!Object.keys(record).length) {
       throw new Error("Record contains no valid field.");
@@ -1489,23 +1491,16 @@ class Addresses extends AutofillRecords 
   }
 }
 
 class CreditCards extends AutofillRecords {
   constructor(store) {
     super(store, "creditCards", VALID_CREDIT_CARD_FIELDS, VALID_CREDIT_CARD_COMPUTED_FIELDS, CREDIT_CARD_SCHEMA_VERSION);
   }
 
-  _getMaskedCCNumber(ccNumber) {
-    if (ccNumber.length <= 4) {
-      throw new Error(`Invalid credit card number`);
-    }
-    return "*".repeat(ccNumber.length - 4) + ccNumber.substr(-4);
-  }
-
   _computeFields(creditCard) {
     // NOTE: Remember to bump the schema version number if any of the existing
     //       computing algorithm changes. (No need to bump when just adding new
     //       computed fields.)
 
     // NOTE: Computed fields should be always present in the storage no matter
     //       it's empty or not.
 
@@ -1533,17 +1528,17 @@ class CreditCards extends AutofillRecord
       }
       hasNewComputedFields = true;
     }
 
     // Encrypt credit card number
     if (!("cc-number-encrypted" in creditCard)) {
       if ("cc-number" in creditCard) {
         let ccNumber = creditCard["cc-number"];
-        creditCard["cc-number"] = this._getMaskedCCNumber(ccNumber);
+        creditCard["cc-number"] = CreditCard.getLongMaskedNumber(ccNumber);
         creditCard["cc-number-encrypted"] = MasterPassword.encryptSync(ccNumber);
       } else {
         creditCard["cc-number-encrypted"] = "";
       }
     }
 
     return hasNewComputedFields;
   }
@@ -1573,102 +1568,36 @@ class CreditCards extends AutofillRecord
     }
     delete creditCard["cc-given-name"];
     delete creditCard["cc-additional-name"];
     delete creditCard["cc-family-name"];
   }
 
   _normalizeCCNumber(creditCard) {
     if (creditCard["cc-number"]) {
-      creditCard["cc-number"] = FormAutofillUtils.normalizeCCNumber(creditCard["cc-number"]);
+      let card = new CreditCard({number: creditCard["cc-number"]});
+      creditCard["cc-number"] = card.number;
       if (!creditCard["cc-number"]) {
         delete creditCard["cc-number"];
       }
     }
   }
 
   _normalizeCCExpirationDate(creditCard) {
-    if (creditCard["cc-exp-month"]) {
-      let expMonth = parseInt(creditCard["cc-exp-month"], 10);
-      if (isNaN(expMonth) || expMonth < 1 || expMonth > 12) {
-        delete creditCard["cc-exp-month"];
-      } else {
-        creditCard["cc-exp-month"] = expMonth;
-      }
-    }
-
-    if (creditCard["cc-exp-year"]) {
-      let expYear = parseInt(creditCard["cc-exp-year"], 10);
-      if (isNaN(expYear) || expYear < 0) {
-        delete creditCard["cc-exp-year"];
-      } else if (expYear < 100) {
-        // Enforce 4 digits years.
-        creditCard["cc-exp-year"] = expYear + 2000;
-      } else {
-        creditCard["cc-exp-year"] = expYear;
-      }
+    let card = new CreditCard({
+      expirationMonth: creditCard["cc-exp-month"],
+      expirationYear: creditCard["cc-exp-year"],
+      expirationString: creditCard["cc-exp"],
+    });
+    if (creditCard["cc-exp-month"] || creditCard["cc-exp"]) {
+      creditCard["cc-exp-month"] = card.expirationMonth;
     }
-
-    if (creditCard["cc-exp"] && (!creditCard["cc-exp-month"] || !creditCard["cc-exp-year"])) {
-      let rules = [
-        {
-          regex: "(\\d{4})[-/](\\d{1,2})",
-          yearIndex: 1,
-          monthIndex: 2,
-        },
-        {
-          regex: "(\\d{1,2})[-/](\\d{4})",
-          yearIndex: 2,
-          monthIndex: 1,
-        },
-        {
-          regex: "(\\d{1,2})[-/](\\d{1,2})",
-        },
-        {
-          regex: "(\\d{2})(\\d{2})",
-        },
-      ];
-
-      for (let rule of rules) {
-        let result = new RegExp(`(?:^|\\D)${rule.regex}(?!\\d)`).exec(creditCard["cc-exp"]);
-        if (!result) {
-          continue;
-        }
-
-        let expYear, expMonth;
-
-        if (!rule.yearIndex || !rule.monthIndex) {
-          expMonth = parseInt(result[1], 10);
-          if (expMonth > 12) {
-            expYear = parseInt(result[1], 10);
-            expMonth = parseInt(result[2], 10);
-          } else {
-            expYear = parseInt(result[2], 10);
-          }
-        } else {
-          expYear = parseInt(result[rule.yearIndex], 10);
-          expMonth = parseInt(result[rule.monthIndex], 10);
-        }
-
-        if (expMonth < 1 || expMonth > 12) {
-          continue;
-        }
-
-        if (expYear < 100) {
-          expYear += 2000;
-        } else if (expYear < 2000) {
-          continue;
-        }
-
-        creditCard["cc-exp-month"] = expMonth;
-        creditCard["cc-exp-year"] = expYear;
-        break;
-      }
+    if (creditCard["cc-exp-year"] || creditCard["cc-exp"]) {
+      creditCard["cc-exp-year"] = card.expirationYear;
     }
-
     delete creditCard["cc-exp"];
   }
 
   /**
    * Normalize the given record and return the first matched guid if storage has the same record.
    * @param {Object} targetCreditCard
    *        The credit card for duplication checking.
    * @returns {string|null}
@@ -1681,17 +1610,17 @@ class CreditCards extends AutofillRecord
       let isDuplicate = this.VALID_FIELDS.every(field => {
         if (!clonedTargetCreditCard[field]) {
           return !creditCard[field];
         }
         if (field == "cc-number" && creditCard[field]) {
           if (MasterPassword.isEnabled) {
             // Compare the masked numbers instead when the master password is
             // enabled because we don't want to leak the credit card number.
-            return this._getMaskedCCNumber(clonedTargetCreditCard[field]) == creditCard[field];
+            return CreditCard.getLongMaskedNumber(clonedTargetCreditCard[field]) == creditCard[field];
           }
           return clonedTargetCreditCard[field] == MasterPassword.decryptSync(creditCard["cc-number-encrypted"]);
         }
         return clonedTargetCreditCard[field] == creditCard[field];
       });
       if (isDuplicate) {
         return creditCard.guid;
       }
--- a/browser/extensions/formautofill/FormAutofillUtils.jsm
+++ b/browser/extensions/formautofill/FormAutofillUtils.jsm
@@ -37,16 +37,18 @@ const SECTION_TYPES = {
 };
 
 // The maximum length of data to be saved in a single field for preventing DoS
 // attacks that fill the user's hard drive(s).
 const MAX_FIELD_VALUE_LENGTH = 200;
 
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 ChromeUtils.import("resource://gre/modules/Services.jsm");
+ChromeUtils.defineModuleGetter(this, "CreditCard",
+  "resource://gre/modules/CreditCard.jsm");
 
 let AddressDataLoader = {
   // Status of address data loading. We'll load all the countries with basic level 1
   // information while requesting conutry information, and set country to true.
   // Level 1 Set is for recording which country's level 1/level 2 data is loaded,
   // since we only load this when getCountryAddressData called with level 1 parameter.
   _dataLoaded: {
     country: false,
@@ -224,27 +226,19 @@ this.FormAutofillUtils = {
   isAddressField(fieldName) {
     return !!this._fieldNameInfo[fieldName] && !this.isCreditCardField(fieldName);
   },
 
   isCreditCardField(fieldName) {
     return this._fieldNameInfo[fieldName] == "creditCard";
   },
 
-  normalizeCCNumber(ccNumber) {
-    ccNumber = ccNumber.replace(/[-\s]/g, "");
-
-    // Based on the information on wiki[1], the shortest valid length should be
-    // 12 digits(Maestro).
-    // [1] https://en.wikipedia.org/wiki/Payment_card_number
-    return ccNumber.match(/^\d{12,}$/) ? ccNumber : null;
-  },
-
   isCCNumber(ccNumber) {
-    return !!this.normalizeCCNumber(ccNumber);
+    let card = new CreditCard({number: ccNumber});
+    return !!card.number;
   },
 
   getCategoryFromFieldName(fieldName) {
     return this._fieldNameInfo[fieldName];
   },
 
   getCategoriesFromFieldNames(fieldNames) {
     let categories = new Set();
@@ -259,52 +253,16 @@ this.FormAutofillUtils = {
 
   getAddressSeparator() {
     // The separator should be based on the L10N address format, and using a
     // white space is a temporary solution.
     return " ";
   },
 
   /**
-   * Get credit card display label. It should display masked numbers and the
-   * cardholder's name, separated by a comma. If `showCreditCards` is set to
-   * true, decrypted credit card numbers are shown instead.
-   *
-   * @param  {object} creditCard
-   * @param  {boolean} showCreditCards [optional]
-   * @returns {string}
-   */
-  getCreditCardLabel(creditCard, showCreditCards = false) {
-    let parts = [];
-    let ccLabel;
-    let ccNumber = creditCard["cc-number"];
-    let decryptedCCNumber = creditCard["cc-number-decrypted"];
-
-    if (showCreditCards && decryptedCCNumber) {
-      ccLabel = decryptedCCNumber;
-    }
-    if (ccNumber && !ccLabel) {
-      if (this.isCCNumber(ccNumber)) {
-        ccLabel = "*".repeat(4) + " " + ccNumber.substr(-4);
-      } else {
-        let {affix, label} = this.fmtMaskedCreditCardLabel(ccNumber);
-        ccLabel = `${affix} ${label}`;
-      }
-    }
-
-    if (ccLabel) {
-      parts.push(ccLabel);
-    }
-    if (creditCard["cc-name"]) {
-      parts.push(creditCard["cc-name"]);
-    }
-    return parts.join(", ");
-  },
-
-  /**
    * Get address display label. It should display up to two pieces of
    * information, separated by a comma.
    *
    * @param  {object} address
    * @returns {string}
    */
   getAddressLabel(address) {
     // TODO: Implement a smarter way for deciding what to display
@@ -374,23 +332,16 @@ this.FormAutofillUtils = {
 
     for (let field in address) {
       if (field != "tel" && this.getCategoryFromFieldName(field) == "tel") {
         delete address[field];
       }
     }
   },
 
-  fmtMaskedCreditCardLabel(maskedCCNum = "") {
-    return {
-      affix: "****",
-      label: maskedCCNum.replace(/^\**/, ""),
-    };
-  },
-
   defineLazyLogGetter(scope, logPrefix) {
     XPCOMUtils.defineLazyGetter(scope, "log", () => {
       let ConsoleAPI = ChromeUtils.import("resource://gre/modules/Console.jsm", {}).ConsoleAPI;
       return new ConsoleAPI({
         maxLogLevelPref: "extensions.formautofill.loglevel",
         prefix: logPrefix,
       });
     });
--- a/browser/extensions/formautofill/ProfileAutoCompleteResult.jsm
+++ b/browser/extensions/formautofill/ProfileAutoCompleteResult.jsm
@@ -6,16 +6,18 @@
 
 "use strict";
 
 var EXPORTED_SYMBOLS = ["AddressResult", "CreditCardResult"];
 
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 ChromeUtils.import("resource://formautofill/FormAutofillUtils.jsm");
+ChromeUtils.defineModuleGetter(this, "CreditCard",
+  "resource://gre/modules/CreditCard.jsm");
 
 XPCOMUtils.defineLazyPreferenceGetter(this, "insecureWarningEnabled", "security.insecure_field_warning.contextual.enabled");
 
 this.log = null;
 FormAutofillUtils.defineLazyLogGetter(this, EXPORTED_SYMBOLS[0]);
 
 class ProfileAutoCompleteResult {
   constructor(searchString, focusedFieldName, allFieldNames, matchingProfiles, {
@@ -334,17 +336,17 @@ class CreditCardResult extends ProfileAu
       }
 
       let matching = GROUP_FIELDS[currentFieldName] ?
         allFieldNames.some(fieldName => GROUP_FIELDS[currentFieldName].includes(fieldName)) :
         allFieldNames.includes(currentFieldName);
 
       if (matching) {
         if (currentFieldName == "cc-number") {
-          let {affix, label} = FormAutofillUtils.fmtMaskedCreditCardLabel(profile[currentFieldName]);
+          let {affix, label} = CreditCard.formatMaskedNumber(profile[currentFieldName]);
           return affix + label;
         }
         return profile[currentFieldName];
       }
     }
 
     return ""; // Nothing matched.
   }
@@ -369,17 +371,17 @@ class CreditCardResult extends ProfileAu
     // Skip results without a primary label.
     let labels = profiles.filter(profile => {
       return !!profile[focusedFieldName];
     }).map(profile => {
       let primaryAffix;
       let primary = profile[focusedFieldName];
 
       if (focusedFieldName == "cc-number") {
-        let {affix, label} = FormAutofillUtils.fmtMaskedCreditCardLabel(primary);
+        let {affix, label} = CreditCard.formatMaskedNumber(primary);
         primaryAffix = affix;
         primary = label;
       }
       return {
         primaryAffix,
         primary,
         secondary: this._getSecondaryLabel(focusedFieldName,
                                            allFieldNames,
--- a/browser/extensions/formautofill/content/manageDialog.js
+++ b/browser/extensions/formautofill/content/manageDialog.js
@@ -8,16 +8,18 @@
 
 const EDIT_ADDRESS_URL = "chrome://formautofill/content/editAddress.xhtml";
 const EDIT_CREDIT_CARD_URL = "chrome://formautofill/content/editCreditCard.xhtml";
 
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 ChromeUtils.import("resource://formautofill/FormAutofillUtils.jsm");
 
+ChromeUtils.defineModuleGetter(this, "CreditCard",
+                               "resource://gre/modules/CreditCard.jsm");
 ChromeUtils.defineModuleGetter(this, "formAutofillStorage",
                                "resource://formautofill/FormAutofillStorage.jsm");
 ChromeUtils.defineModuleGetter(this, "MasterPassword",
                                "resource://formautofill/MasterPassword.jsm");
 
 this.log = null;
 FormAutofillUtils.defineLazyLogGetter(this, "manageAddresses");
 
@@ -329,27 +331,27 @@ class ManageCreditCards extends ManageRe
     }
   }
 
   /**
    * Get credit card display label. It should display masked numbers and the
    * cardholder's name, separated by a comma. If `showCreditCards` is set to
    * true, decrypted credit card numbers are shown instead.
    *
-   * @param  {object} creditCard
-   * @param  {boolean} showCreditCards [optional]
+   * @param {object} creditCard
+   * @param {boolean} showCreditCards [optional]
    * @returns {string}
    */
   async getLabel(creditCard, showCreditCards = false) {
-    let patchObj = {};
-    if (creditCard["cc-number"] && showCreditCards) {
-      patchObj["cc-number-decrypted"] = await MasterPassword.decrypt(creditCard["cc-number-encrypted"]);
-    }
-
-    return FormAutofillUtils.getCreditCardLabel({...creditCard, ...patchObj}, showCreditCards);
+    let cardObj = new CreditCard({
+      encryptedNumber: creditCard["cc-number-encrypted"],
+      number: creditCard["cc-number"],
+      name: creditCard["cc-name"],
+    });
+    return cardObj.getLabel({showNumbers: showCreditCards});
   }
 
   async toggleShowHideCards(options) {
     this._isDecrypted = !this._isDecrypted;
     this.updateShowHideButtonState();
     await this.updateLabels(options, this._isDecrypted);
   }
 
--- a/browser/extensions/formautofill/test/browser/browser_creditCard_doorhanger.js
+++ b/browser/extensions/formautofill/test/browser/browser_creditCard_doorhanger.js
@@ -13,17 +13,17 @@ add_task(async function test_submit_cred
                                                        "popupshown");
       await ContentTask.spawn(browser, null, async function() {
         let form = content.document.getElementById("form");
         let name = form.querySelector("#cc-name");
         name.focus();
         name.setUserInput("User 1");
 
         let number = form.querySelector("#cc-number");
-        number.setUserInput("1111222233334444");
+        number.setUserInput("5038146897157463");
 
         // Wait 1000ms before submission to make sure the input value applied
         await new Promise(resolve => setTimeout(resolve, 1000));
         form.querySelector("input[type=submit]").click();
       });
 
       ok(!SpecialPowers.Services.prefs.prefHasUserValue(SYNC_USERNAME_PREF),
          "Sync account should not exist by default");
@@ -52,17 +52,17 @@ add_task(async function test_submit_cred
       let promiseShown = BrowserTestUtils.waitForEvent(PopupNotifications.panel,
                                                        "popupshown");
       await ContentTask.spawn(browser, null, async function() {
         let form = content.document.getElementById("form");
         let name = form.querySelector("#cc-name");
         name.focus();
         name.setUserInput("User 1");
 
-        form.querySelector("#cc-number").setUserInput("1111222233334444");
+        form.querySelector("#cc-number").setUserInput("5038146897157463");
         form.querySelector("#cc-exp-month").setUserInput("12");
         form.querySelector("#cc-exp-year").setUserInput("2017");
 
         // Wait 1000ms before submission to make sure the input value applied
         await new Promise(resolve => setTimeout(resolve, 1000));
         form.querySelector("input[type=submit]").click();
       });
 
@@ -130,17 +130,17 @@ add_task(async function test_submit_chan
       await ContentTask.spawn(browser, null, async function() {
         let form = content.document.getElementById("form");
         let name = form.querySelector("#cc-name");
 
         name.focus();
         await new Promise(resolve => setTimeout(resolve, 1000));
         name.setUserInput("");
 
-        form.querySelector("#cc-number").setUserInput("1234567812345678");
+        form.querySelector("#cc-number").setUserInput("4111111111111111");
         form.querySelector("#cc-exp-month").setUserInput("4");
         form.querySelector("#cc-exp-year").setUserInput(new Date().getFullYear());
         // Wait 1000ms before submission to make sure the input value applied
         await new Promise(resolve => setTimeout(resolve, 1000));
         form.querySelector("input[type=submit]").click();
       });
 
       await promiseShown;
@@ -168,17 +168,17 @@ add_task(async function test_submit_dupl
   await BrowserTestUtils.withNewTab({gBrowser, url: CREDITCARD_FORM_URL},
     async function(browser) {
       await ContentTask.spawn(browser, null, async function() {
         let form = content.document.getElementById("form");
         let name = form.querySelector("#cc-name");
         name.focus();
 
         name.setUserInput("John Doe");
-        form.querySelector("#cc-number").setUserInput("1234567812345678");
+        form.querySelector("#cc-number").setUserInput("4111111111111111");
         form.querySelector("#cc-exp-month").setUserInput("4");
         form.querySelector("#cc-exp-year").setUserInput(new Date().getFullYear());
 
         // Wait 1000ms before submission to make sure the input value applied
         await new Promise(resolve => setTimeout(resolve, 1000));
         form.querySelector("input[type=submit]").click();
       });
 
@@ -204,17 +204,17 @@ add_task(async function test_submit_unno
   await BrowserTestUtils.withNewTab({gBrowser, url: CREDITCARD_FORM_URL},
     async function(browser) {
       await ContentTask.spawn(browser, null, async function() {
         let form = content.document.getElementById("form");
         let name = form.querySelector("#cc-name");
         name.focus();
 
         name.setUserInput("John Doe");
-        form.querySelector("#cc-number").setUserInput("1234567812345678");
+        form.querySelector("#cc-number").setUserInput("4111111111111111");
         form.querySelector("#cc-exp-month").setUserInput("4");
         // Set unnormalized year
         form.querySelector("#cc-exp-year").setUserInput(new Date().getFullYear().toString().substr(2, 2));
 
         // Wait 1000ms before submission to make sure the input value applied
         await new Promise(resolve => setTimeout(resolve, 1000));
         form.querySelector("input[type=submit]").click();
       });
@@ -243,17 +243,17 @@ add_task(async function test_submit_cred
       await ContentTask.spawn(browser, null, async function() {
         let form = content.document.getElementById("form");
         let name = form.querySelector("#cc-name");
         name.focus();
         await new Promise(resolve => setTimeout(resolve, 1000));
         name.setUserInput("User 0");
 
         let number = form.querySelector("#cc-number");
-        number.setUserInput("1234123412341234");
+        number.setUserInput("6387060366272981");
 
         // Wait 1000ms before submission to make sure the input value applied
         await new Promise(resolve => setTimeout(resolve, 1000));
         form.querySelector("input[type=submit]").click();
       });
 
       await promiseShown;
       await clickDoorhangerButton(MENU_BUTTON, 0);
@@ -281,34 +281,34 @@ add_task(async function test_submit_cred
       await ContentTask.spawn(browser, null, async function() {
         let form = content.document.getElementById("form");
         let name = form.querySelector("#cc-name");
         name.focus();
         await new Promise(resolve => setTimeout(resolve, 1000));
         name.setUserInput("User 0");
 
         let number = form.querySelector("#cc-number");
-        number.setUserInput("1234123412341234");
+        number.setUserInput("6387060366272981");
 
         // Wait 1000ms before submission to make sure the input value applied
         await new Promise(resolve => setTimeout(resolve, 1000));
         form.querySelector("input[type=submit]").click();
       });
 
       await promiseShown;
       await clickDoorhangerButton(MAIN_BUTTON);
       await masterPasswordDialogShown;
       await TestUtils.topicObserved("formautofill-storage-changed");
     }
   );
 
   let creditCards = await getCreditCards();
   is(creditCards.length, 1, "1 credit card in storage");
   is(creditCards[0]["cc-name"], "User 0", "Verify the name field");
-  is(creditCards[0]["cc-number"], "************1234", "Verify the card number field");
+  is(creditCards[0]["cc-number"], "************2981", "Verify the card number field");
   LoginTestUtils.masterPassword.disable();
   await removeAllRecords();
 });
 
 add_task(async function test_submit_creditCard_saved_with_mp_enabled_but_canceled() {
   LoginTestUtils.masterPassword.enable();
   let masterPasswordDialogShown = waitForMasterPasswordDialog();
   await BrowserTestUtils.withNewTab({gBrowser, url: CREDITCARD_FORM_URL},
@@ -318,17 +318,17 @@ add_task(async function test_submit_cred
       await ContentTask.spawn(browser, null, async function() {
         let form = content.document.getElementById("form");
         let name = form.querySelector("#cc-name");
         name.focus();
         await new Promise(resolve => setTimeout(resolve, 1000));
         name.setUserInput("User 2");
 
         let number = form.querySelector("#cc-number");
-        number.setUserInput("5678567856785678");
+        number.setUserInput("5471839082338112");
 
         // Wait 1000ms before submission to make sure the input value applied
         await new Promise(resolve => setTimeout(resolve, 1000));
         form.querySelector("input[type=submit]").click();
       });
 
       await promiseShown;
       await clickDoorhangerButton(MAIN_BUTTON);
@@ -356,17 +356,17 @@ add_task(async function test_submit_cred
                                                        "popupshown");
       await ContentTask.spawn(browser, null, async function() {
         let form = content.document.getElementById("form");
         let name = form.querySelector("#cc-name");
         name.focus();
         name.setUserInput("User 2");
 
         let number = form.querySelector("#cc-number");
-        number.setUserInput("1234123412341234");
+        number.setUserInput("6387060366272981");
 
         // Wait 500ms before submission to make sure the input value applied
         await new Promise(resolve => setTimeout(resolve, 500));
         form.querySelector("input[type=submit]").click();
       });
 
       await promiseShown;
       let cb = getDoorhangerCheckbox();
@@ -412,17 +412,17 @@ add_task(async function test_submit_cred
                                                        "popupshown");
       await ContentTask.spawn(browser, null, async function() {
         let form = content.document.getElementById("form");
         let name = form.querySelector("#cc-name");
         name.focus();
         name.setUserInput("User 2");
 
         let number = form.querySelector("#cc-number");
-        number.setUserInput("1234123412341234");
+        number.setUserInput("6387060366272981");
 
         // Wait 500ms before submission to make sure the input value applied
         await new Promise(resolve => setTimeout(resolve, 500));
         form.querySelector("input[type=submit]").click();
       });
 
       await promiseShown;
       let cb = getDoorhangerCheckbox();
@@ -446,17 +446,17 @@ add_task(async function test_submit_manu
       let promiseShown = BrowserTestUtils.waitForEvent(PopupNotifications.panel,
                                                        "popupshown");
       await ContentTask.spawn(browser, null, async function() {
         let form = content.document.getElementById("form");
         let name = form.querySelector("#cc-name");
         name.focus();
 
         name.setUserInput("User 3");
-        form.querySelector("#cc-number").setUserInput("9999888877776666");
+        form.querySelector("#cc-number").setUserInput("5103059495477870");
         form.querySelector("#cc-exp-month").setUserInput("1");
         form.querySelector("#cc-exp-year").setUserInput("2000");
 
         // Wait 1000ms before submission to make sure the input value applied
         await new Promise(resolve => setTimeout(resolve, 1000));
         form.querySelector("input[type=submit]").click();
       });
       await promiseShown;
@@ -501,17 +501,17 @@ add_task(async function test_update_auto
       await promiseShown;
       await clickDoorhangerButton(MAIN_BUTTON);
     }
   );
 
   creditCards = await getCreditCards();
   is(creditCards.length, 1, "Still 1 credit card");
   is(creditCards[0]["cc-name"], "User 1", "cc-name field is updated");
-  is(creditCards[0]["cc-number"], "************5678", "Verify the card number field");
+  is(creditCards[0]["cc-number"], "************1111", "Verify the card number field");
   is(SpecialPowers.getIntPref(CREDITCARDS_USED_STATUS_PREF), 3, "User has used autofill");
   SpecialPowers.clearUserPref(CREDITCARDS_USED_STATUS_PREF);
   await removeAllRecords();
 });
 
 add_task(async function test_update_autofill_form_exp_date() {
   await SpecialPowers.pushPrefEnv({
     "set": [
@@ -541,17 +541,17 @@ add_task(async function test_update_auto
       await promiseShown;
       await clickDoorhangerButton(MAIN_BUTTON);
     }
   );
 
   creditCards = await getCreditCards();
   is(creditCards.length, 1, "Still 1 credit card");
   is(creditCards[0]["cc-exp-year"], "2020", "cc-exp-year field is updated");
-  is(creditCards[0]["cc-number"], "************5678", "Verify the card number field");
+  is(creditCards[0]["cc-number"], "************1111", "Verify the card number field");
   is(SpecialPowers.getIntPref(CREDITCARDS_USED_STATUS_PREF), 3, "User has used autofill");
   SpecialPowers.clearUserPref(CREDITCARDS_USED_STATUS_PREF);
   await removeAllRecords();
 });
 
 add_task(async function test_create_new_autofill_form() {
   await SpecialPowers.pushPrefEnv({
     "set": [
@@ -595,34 +595,34 @@ add_task(async function test_create_new_
 
 add_task(async function test_update_duplicate_autofill_form() {
   await SpecialPowers.pushPrefEnv({
     "set": [
       [CREDITCARDS_USED_STATUS_PREF, 0],
     ],
   });
   await saveCreditCard({
-    "cc-number": "1234123412341234",
+    "cc-number": "6387060366272981",
   });
   await saveCreditCard({
-    "cc-number": "1111222233334444",
+    "cc-number": "5038146897157463",
   });
   let creditCards = await getCreditCards();
   is(creditCards.length, 2, "2 credit card in storage");
   await BrowserTestUtils.withNewTab({gBrowser, url: CREDITCARD_FORM_URL},
     async function(browser) {
       await openPopupOn(browser, "form #cc-number");
       await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
       await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser);
       await ContentTask.spawn(browser, null, async function() {
         let form = content.document.getElementById("form");
         let number = form.querySelector("#cc-number");
-        is(number.value, "1234123412341234", "Should be the first credit card number");
+        is(number.value, "6387060366272981", "Should be the first credit card number");
         // Change number to the second credit card number
-        number.setUserInput("1111222233334444");
+        number.setUserInput("5038146897157463");
 
         // Wait 1000ms before submission to make sure the input value applied
         await new Promise(resolve => setTimeout(resolve, 1000));
         form.querySelector("input[type=submit]").click();
       });
 
       await sleep(1000);
       is(PopupNotifications.panel.state, "closed", "Doorhanger is hidden");
--- a/browser/extensions/formautofill/test/browser/browser_manageCreditCardsDialog.js
+++ b/browser/extensions/formautofill/test/browser/browser_manageCreditCardsDialog.js
@@ -46,19 +46,19 @@ add_task(async function test_removingSin
   let win = window.openDialog(MANAGE_CREDIT_CARDS_DIALOG_URL, null, DIALOG_SIZE);
   await waitForFocusAndFormReady(win);
 
   let selRecords = win.document.querySelector(TEST_SELECTORS.selRecords);
   let btnRemove = win.document.querySelector(TEST_SELECTORS.btnRemove);
   let btnEdit = win.document.querySelector(TEST_SELECTORS.btnEdit);
 
   is(selRecords.length, 3, "Three credit cards");
-  is(selRecords[0].text, "**** 6666", "Masked credit card 3");
-  is(selRecords[1].text, "**** 4444, Timothy Berners-Lee", "Masked credit card 2");
-  is(selRecords[2].text, "**** 5678, John Doe", "Masked credit card 1");
+  is(selRecords[0].text, "**** 7870", "Masked credit card 3");
+  is(selRecords[1].text, "**** 1045, Timothy Berners-Lee", "Masked credit card 2");
+  is(selRecords[2].text, "**** 1111, John Doe", "Masked credit card 1");
 
   EventUtils.synthesizeMouseAtCenter(selRecords.children[0], {}, win);
   is(btnRemove.disabled, false, "Remove button enabled");
   is(btnEdit.disabled, false, "Edit button enabled");
   EventUtils.synthesizeMouseAtCenter(btnRemove, {}, win);
   await BrowserTestUtils.waitForEvent(selRecords, "RecordsRemoved");
   is(selRecords.length, 2, "Two credit cards left");
 
@@ -120,39 +120,39 @@ add_task(async function test_showCreditC
   let btnShowHideCreditCards = win.document.querySelector(TEST_SELECTORS.btnShowHideCreditCards);
 
   is(btnShowHideCreditCards.disabled, false, "Show credit cards button enabled");
   is(btnShowHideCreditCards.textContent, "Show Credit Cards", "Label should be 'Show Credit Cards'");
 
   // Show credit card numbers
   EventUtils.synthesizeMouseAtCenter(btnShowHideCreditCards, {}, win);
   await BrowserTestUtils.waitForEvent(selRecords, "LabelsUpdated");
-  is(selRecords[0].text, "9999888877776666", "Decrypted credit card 3");
-  is(selRecords[1].text, "1111222233334444, Timothy Berners-Lee", "Decrypted credit card 2");
-  is(selRecords[2].text, "1234567812345678, John Doe", "Decrypted credit card 1");
+  is(selRecords[0].text, "5103059495477870", "Decrypted credit card 3");
+  is(selRecords[1].text, "4929001587121045, Timothy Berners-Lee", "Decrypted credit card 2");
+  is(selRecords[2].text, "4111111111111111, John Doe", "Decrypted credit card 1");
   is(btnShowHideCreditCards.textContent, "Hide Credit Cards", "Label should be 'Hide Credit Cards'");
 
   // Hide credit card numbers
   EventUtils.synthesizeMouseAtCenter(btnShowHideCreditCards, {}, win);
   await BrowserTestUtils.waitForEvent(selRecords, "LabelsUpdated");
-  is(selRecords[0].text, "**** 6666", "Masked credit card 3");
-  is(selRecords[1].text, "**** 4444, Timothy Berners-Lee", "Masked credit card 2");
-  is(selRecords[2].text, "**** 5678, John Doe", "Masked credit card 1");
+  is(selRecords[0].text, "**** 7870", "Masked credit card 3");
+  is(selRecords[1].text, "**** 1045, Timothy Berners-Lee", "Masked credit card 2");
+  is(selRecords[2].text, "**** 1111, John Doe", "Masked credit card 1");
   is(btnShowHideCreditCards.textContent, "Show Credit Cards", "Label should be 'Show Credit Cards'");
 
   // Show credit card numbers again to test if they revert back to masked form when reloaded
   EventUtils.synthesizeMouseAtCenter(btnShowHideCreditCards, {}, win);
   await BrowserTestUtils.waitForEvent(selRecords, "LabelsUpdated");
   // Ensure credit card numbers are shown again
-  is(selRecords[0].text, "9999888877776666", "Decrypted credit card 3");
+  is(selRecords[0].text, "5103059495477870", "Decrypted credit card 3");
   // Remove a card to trigger reloading
   await removeCreditCards([selRecords.options[2].value]);
   await BrowserTestUtils.waitForEvent(selRecords, "RecordsLoaded");
-  is(selRecords[0].text, "**** 6666", "Masked credit card 3");
-  is(selRecords[1].text, "**** 4444, Timothy Berners-Lee", "Masked credit card 2");
+  is(selRecords[0].text, "**** 7870", "Masked credit card 3");
+  is(selRecords[1].text, "**** 1045, Timothy Berners-Lee", "Masked credit card 2");
 
   // Remove the rest of the cards
   await removeCreditCards([selRecords.options[1].value]);
   await removeCreditCards([selRecords.options[0].value]);
   await BrowserTestUtils.waitForEvent(selRecords, "RecordsLoaded");
   is(btnShowHideCreditCards.disabled, true, "Show credit cards button is disabled when there is no card");
 
   win.close();
--- a/browser/extensions/formautofill/test/browser/head.js
+++ b/browser/extensions/formautofill/test/browser/head.js
@@ -95,30 +95,30 @@ const TEST_ADDRESS_DE_1 = {
   "postal-code": "10997",
   country: "DE",
   tel: "+4930983333000",
   email: "timbl@w3.org",
 };
 
 const TEST_CREDIT_CARD_1 = {
   "cc-name": "John Doe",
-  "cc-number": "1234567812345678",
+  "cc-number": "4111111111111111",
   "cc-exp-month": 4,
   "cc-exp-year": new Date().getFullYear(),
 };
 
 const TEST_CREDIT_CARD_2 = {
   "cc-name": "Timothy Berners-Lee",
-  "cc-number": "1111222233334444",
+  "cc-number": "4929001587121045",
   "cc-exp-month": 12,
   "cc-exp-year": new Date().getFullYear() + 10,
 };
 
 const TEST_CREDIT_CARD_3 = {
-  "cc-number": "9999888877776666",
+  "cc-number": "5103059495477870",
   "cc-exp-month": 1,
   "cc-exp-year": 2000,
 };
 
 const MAIN_BUTTON = "button";
 const SECONDARY_BUTTON = "secondaryButton";
 const MENU_BUTTON = "menubutton";
 
--- a/browser/extensions/formautofill/test/mochitest/formautofill_common.js
+++ b/browser/extensions/formautofill/test/mochitest/formautofill_common.js
@@ -197,19 +197,21 @@ async function cleanUpCreditCards() {
 }
 
 async function cleanUpStorage() {
   await cleanUpAddresses();
   await cleanUpCreditCards();
 }
 
 function patchRecordCCNumber(record) {
-  const ccNumber = record["cc-number"];
-  const normalizedCCNumber = "*".repeat(ccNumber.length - 4) + ccNumber.substr(-4);
-  const ccNumberFmt = FormAutofillUtils.fmtMaskedCreditCardLabel(normalizedCCNumber);
+  const number = record["cc-number"];
+  const ccNumberFmt = {
+    affix: "****",
+    label: number.substr(-4),
+  };
 
   return Object.assign({}, record, {ccNumberFmt});
 }
 
 // Utils for registerPopupShownListener(in satchel_common.js) that handles dropdown popup
 // Please call "initPopupListener()" in your test and "await expectPopup()"
 // if you want to wait for dropdown menu displayed.
 function expectPopup() {
--- a/browser/extensions/formautofill/test/mochitest/test_basic_creditcard_autocomplete_form.html
+++ b/browser/extensions/formautofill/test/mochitest/test_basic_creditcard_autocomplete_form.html
@@ -17,29 +17,29 @@ Form autofill test: simple form credit c
 /* import-globals-from ../../../../../testing/mochitest/tests/SimpleTest/AddTask.js */
 /* import-globals-from ../../../../../toolkit/components/satchel/test/satchel_common.js */
 /* import-globals-from formautofill_common.js */
 
 "use strict";
 
 const MOCK_STORAGE = [{
   "cc-name": "John Doe",
-  "cc-number": "1234567812345678",
+  "cc-number": "4929001587121045",
   "cc-exp-month": 4,
   "cc-exp-year": 2017,
 }, {
   "cc-name": "Timothy Berners-Lee",
-  "cc-number": "1111222233334444",
+  "cc-number": "5103059495477870",
   "cc-exp-month": 12,
   "cc-exp-year": 2022,
 }];
 
 const reducedMockRecord = {
   "cc-name": "John Doe",
-  "cc-number": "1234123456785678",
+  "cc-number": "4929001587121045",
 };
 
 async function setupCreditCardStorage() {
   await addCreditCard(MOCK_STORAGE[0]);
   await addCreditCard(MOCK_STORAGE[1]);
 }
 
 async function setupFormHistory() {
--- a/browser/extensions/formautofill/test/mochitest/test_clear_form.html
+++ b/browser/extensions/formautofill/test/mochitest/test_clear_form.html
@@ -28,22 +28,22 @@ const MOCK_ADDR_STORAGE = [{
   organization: "Mozilla",
   "street-address": "331 E. Evelyn Avenue",
 }, {
   organization: "Tel org",
   tel: "+12223334444",
 }];
 const MOCK_CC_STORAGE = [{
   "cc-name": "John Doe",
-  "cc-number": "1234567812345678",
+  "cc-number": "4929001587121045",
   "cc-exp-month": 4,
   "cc-exp-year": 2017,
 }, {
   "cc-name": "Timothy Berners-Lee",
-  "cc-number": "1111222233334444",
+  "cc-number": "5103059495477870",
   "cc-exp-month": 12,
   "cc-exp-year": 2022,
 }];
 
 initPopupListener();
 
 add_task(async function setup_storage() {
   await addAddress(MOCK_ADDR_STORAGE[0]);
--- a/browser/extensions/formautofill/test/mochitest/test_creditcard_autocomplete_off.html
+++ b/browser/extensions/formautofill/test/mochitest/test_creditcard_autocomplete_off.html
@@ -17,48 +17,48 @@ Form autofill test: simple form credit c
 /* import-globals-from ../../../../../testing/mochitest/tests/SimpleTest/AddTask.js */
 /* import-globals-from ../../../../../toolkit/components/satchel/test/satchel_common.js */
 /* import-globals-from formautofill_common.js */
 
 "use strict";
 
 const MOCK_STORAGE = [{
   "cc-name": "John Doe",
-  "cc-number": "1234567812345678",
+  "cc-number": "4929001587121045",
   "cc-exp-month": 4,
   "cc-exp-year": 2017,
 }, {
   "cc-name": "Timothy Berners-Lee",
-  "cc-number": "1111222233334444",
+  "cc-number": "5103059495477870",
   "cc-exp-month": 12,
   "cc-exp-year": 2022,
 }];
 
 async function setupCreditCardStorage() {
   await addCreditCard(MOCK_STORAGE[0]);
   await addCreditCard(MOCK_STORAGE[1]);
 }
 
 async function setupFormHistory() {
   await updateFormHistory([
     {op: "add", fieldname: "cc-name", value: "John Smith"},
-    {op: "add", fieldname: "cc-number", value: "1234000056780000"},
+    {op: "add", fieldname: "cc-number", value: "6011029476355493"},
   ]);
 }
 
 initPopupListener();
 
 // Show Form History popup for non-autocomplete="off" field only
 add_task(async function history_only_menu_checking() {
   await setupFormHistory();
 
   await setInput("#cc-number", "");
   synthesizeKey("KEY_ArrowDown");
   await expectPopup();
-  checkMenuEntries(["1234000056780000"], false);
+  checkMenuEntries(["6011029476355493"], false);
 
   await setInput("#cc-name", "");
   synthesizeKey("KEY_ArrowDown");
   await notExpectPopup();
 });
 
 // Show Form Autofill popup for the credit card fields.
 add_task(async function check_menu_when_both_with_autocomplete_off() {
--- a/toolkit/components/satchel/formSubmitListener.js
+++ b/toolkit/components/satchel/formSubmitListener.js
@@ -1,14 +1,17 @@
 /* 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/. */
 
 /* eslint-env mozilla/frame-script */
 
+ChromeUtils.defineModuleGetter(this, "CreditCard",
+                               "resource://gre/modules/CreditCard.jsm");
+
 (function() {
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://gre/modules/PrivateBrowsingUtils.jsm");
 
 let satchelFormListener = {
   QueryInterface: ChromeUtils.generateQI([
     Ci.nsIFormSubmitObserver,
@@ -26,46 +29,16 @@ let satchelFormListener = {
     this.updatePrefs();
   },
 
   updatePrefs() {
     this.debug          = Services.prefs.getBoolPref("browser.formfill.debug");
     this.enabled        = Services.prefs.getBoolPref("browser.formfill.enable");
   },
 
-  // Implements the Luhn checksum algorithm as described at
-  // http://wikipedia.org/wiki/Luhn_algorithm
-  isValidCCNumber(ccNumber) {
-    // Remove dashes and whitespace
-    ccNumber = ccNumber.replace(/[\-\s]/g, "");
-
-    let len = ccNumber.length;
-    if (len != 9 && len != 15 && len != 16) {
-      return false;
-    }
-
-    if (!/^\d+$/.test(ccNumber)) {
-      return false;
-    }
-
-    let total = 0;
-    for (let i = 0; i < len; i++) {
-      let ch = parseInt(ccNumber[len - i - 1], 10);
-      if (i % 2 == 1) {
-        // Double it, add digits together if > 10
-        ch *= 2;
-        if (ch > 9) {
-          ch -= 9;
-        }
-      }
-      total += ch;
-    }
-    return total % 10 == 0;
-  },
-
   log(message) {
     if (!this.debug) {
       return;
     }
     dump("satchelFormListener: " + message + "\n");
     Services.console.logStringMessage("satchelFormListener: " + message);
   },
 
@@ -122,17 +95,17 @@ let satchelFormListener = {
         let value = input.value.trim();
 
         // Don't save empty or unchanged values.
         if (!value || value == input.defaultValue.trim()) {
           continue;
         }
 
         // Don't save credit card numbers.
-        if (this.isValidCCNumber(value)) {
+        if (CreditCard.isValidNumber(value)) {
           this.log("skipping saving a credit card number");
           continue;
         }
 
         let name = input.name || input.id;
         if (!name) {
           continue;
         }
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/CreditCard.jsm
@@ -0,0 +1,290 @@
+/* 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";
+
+var EXPORTED_SYMBOLS = ["CreditCard"];
+
+ChromeUtils.defineModuleGetter(this, "MasterPassword",
+                               "resource://formautofill/MasterPassword.jsm");
+
+class CreditCard {
+  /**
+   * @param {string} name
+   * @param {string} number
+   * @param {string} expirationString
+   * @param {string|number} expirationMonth
+   * @param {string|number} expirationYear
+   * @param {string|number} ccv
+   * @param {string} encryptedNumber
+   */
+  constructor({
+    name,
+    number,
+    expirationString,
+    expirationMonth,
+    expirationYear,
+    ccv,
+    encryptedNumber
+  }) {
+    this._name = name;
+    this._unmodifiedNumber = number;
+    this._encryptedNumber = encryptedNumber;
+    this._ccv = ccv;
+    this.number = number;
+    this.expirationMonth = expirationMonth;
+    this.expirationYear = expirationYear;
+  }
+
+  set name(value) {
+    this._name = value;
+  }
+
+  set expirationMonth(value) {
+    if (typeof value == "undefined") {
+      this._expirationMonth = undefined;
+      return;
+    }
+    this._expirationMonth = this._normalizeExpirationMonth(value);
+  }
+
+  get expirationMonth() {
+    return this._expirationMonth;
+  }
+
+  set expirationYear(value) {
+    if (typeof value == "undefined") {
+      this._expirationYear = undefined;
+      return;
+    }
+    this._expirationYear = this._normalizeExpirationYear(value);
+  }
+
+  get expirationYear() {
+    return this._expirationYear;
+  }
+
+  set expirationString(value) {
+    let {month, year} = this._parseExpirationString(value);
+    this.expirationMonth = month;
+    this.expirationYear = year;
+  }
+
+  set ccv(value) {
+    this._ccv = value;
+  }
+
+  get number() {
+    return this._number;
+  }
+
+  set number(value) {
+    if (value) {
+      let normalizedNumber = value.replace(/[-\s]/g, "");
+      // Based on the information on wiki[1], the shortest valid length should be
+      // 9 digits (Canadian SIN).
+      // [1] https://en.wikipedia.org/wiki/Social_Insurance_Number
+      normalizedNumber = normalizedNumber.match(/^\d{9,}$/) ?
+        normalizedNumber : null;
+      this._number = normalizedNumber;
+    }
+  }
+
+  // Implements the Luhn checksum algorithm as described at
+  // http://wikipedia.org/wiki/Luhn_algorithm
+  isValidNumber() {
+    if (!this._number) {
+      return false;
+    }
+
+    // Remove dashes and whitespace
+    let number = this._number.replace(/[\-\s]/g, "");
+
+    let len = number.length;
+    if (len != 9 && len != 15 && len != 16) {
+      return false;
+    }
+
+    if (!/^\d+$/.test(number)) {
+      return false;
+    }
+
+    let total = 0;
+    for (let i = 0; i < len; i++) {
+      let ch = parseInt(number[len - i - 1], 10);
+      if (i % 2 == 1) {
+        // Double it, add digits together if > 10
+        ch *= 2;
+        if (ch > 9) {
+          ch -= 9;
+        }
+      }
+      total += ch;
+    }
+    return total % 10 == 0;
+  }
+
+  /**
+   * Returns true if the card number is valid and the
+   * expiration date has not passed. Otherwise false.
+   *
+   * @returns {boolean}
+   */
+  isValid() {
+    if (!this.isValidNumber()) {
+      return false;
+    }
+
+    let currentDate = new Date();
+    let currentYear = currentDate.getFullYear();
+    if (this._expirationYear > currentYear) {
+      return true;
+    }
+
+    // getMonth is 0-based, so add 1 because credit cards are 1-based
+    let currentMonth = currentDate.getMonth() + 1;
+    return this._expirationYear == currentYear &&
+           this._expirationMonth >= currentMonth;
+  }
+
+  get maskedNumber() {
+    if (!this.isValidNumber()) {
+      throw new Error("Invalid credit card number");
+    }
+    return "*".repeat(4) + " " + this._number.substr(-4);
+  }
+
+  get longMaskedNumber() {
+    if (!this.isValidNumber()) {
+      throw new Error("Invalid credit card number");
+    }
+    return "*".repeat(this.number.length - 4) + this.number.substr(-4);
+  }
+
+  /**
+   * Get credit card display label. It should display masked numbers and the
+   * cardholder's name, separated by a comma. If `showNumbers` is set to
+   * true, decrypted credit card numbers are shown instead.
+   */
+  async getLabel({showNumbers} = {}) {
+    let parts = [];
+    let label;
+
+    if (showNumbers) {
+      if (this._encryptedNumber) {
+        label = await MasterPassword.decrypt(this._encryptedNumber);
+      } else {
+        label = this._number;
+      }
+    }
+    if (this._unmodifiedNumber && !label) {
+      if (this.isValidNumber()) {
+        label = this.maskedNumber;
+      } else {
+        let maskedNumber = CreditCard.formatMaskedNumber(this._unmodifiedNumber);
+        label = `${maskedNumber.affix} ${maskedNumber.label}`;
+      }
+    }
+
+    if (label) {
+      parts.push(label);
+    }
+    if (this._name) {
+      parts.push(this._name);
+    }
+    return parts.join(", ");
+  }
+
+  _normalizeExpirationMonth(month) {
+    month = parseInt(month, 10);
+    if (isNaN(month) || month < 1 || month > 12) {
+      return NaN;
+    }
+    return month;
+  }
+
+  _normalizeExpirationYear(year) {
+    year = parseInt(year, 10);
+    if (isNaN(year) || year < 0) {
+      return NaN;
+    }
+    if (year < 100) {
+      year += 2000;
+    }
+    return year;
+  }
+
+  _parseExpirationString(expirationString) {
+    let rules = [
+      {
+        regex: "(\\d{4})[-/](\\d{1,2})",
+        yearIndex: 1,
+        monthIndex: 2,
+      },
+      {
+        regex: "(\\d{1,2})[-/](\\d{4})",
+        yearIndex: 2,
+        monthIndex: 1,
+      },
+      {
+        regex: "(\\d{1,2})[-/](\\d{1,2})",
+      },
+      {
+        regex: "(\\d{2})(\\d{2})",
+      },
+    ];
+
+    for (let rule of rules) {
+      let result = new RegExp(`(?:^|\\D)${rule.regex}(?!\\d)`).exec(expirationString);
+      if (!result) {
+        continue;
+      }
+
+      let year, month;
+
+      if (!rule.yearIndex || !rule.monthIndex) {
+        month = parseInt(result[1], 10);
+        if (month > 12) {
+          year = parseInt(result[1], 10);
+          month = parseInt(result[2], 10);
+        } else {
+          year = parseInt(result[2], 10);
+        }
+      } else {
+        year = parseInt(result[rule.yearIndex], 10);
+        month = parseInt(result[rule.monthIndex], 10);
+      }
+
+      if ((month < 1 || month > 12) ||
+          (year >= 100 && year < 2000)) {
+        continue;
+      }
+
+      return {month, year};
+    }
+    return {month: NaN, year: NaN};
+  }
+
+  static formatMaskedNumber(maskedNumber) {
+    return {
+      affix: "****",
+      label: maskedNumber.replace(/^\**/, ""),
+    };
+  }
+
+  static getMaskedNumber(number) {
+    let creditCard = new CreditCard({number});
+    return creditCard.maskedNumber;
+  }
+
+  static getLongMaskedNumber(number) {
+    let creditCard = new CreditCard({number});
+    return creditCard.longMaskedNumber;
+  }
+
+  static isValidNumber(number) {
+    let creditCard = new CreditCard({number});
+    return creditCard.isValidNumber();
+  }
+}
--- a/toolkit/modules/moz.build
+++ b/toolkit/modules/moz.build
@@ -184,16 +184,17 @@ EXTRA_JS_MODULES += [
     'BinarySearch.jsm',
     'BrowserUtils.jsm',
     'CanonicalJSON.jsm',
     'CertUtils.jsm',
     'CharsetMenu.jsm',
     'ClientID.jsm',
     'Color.jsm',
     'Console.jsm',
+    'CreditCard.jsm',
     'css-selector.js',
     'DateTimePickerHelper.jsm',
     'DeferredTask.jsm',
     'Deprecated.jsm',
     'E10SUtils.jsm',
     'EventEmitter.jsm',
     'FileUtils.jsm',
     'Finder.jsm',
--- a/toolkit/modules/sessionstore/FormData.jsm
+++ b/toolkit/modules/sessionstore/FormData.jsm
@@ -2,16 +2,19 @@
  * 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";
 
 var EXPORTED_SYMBOLS = ["FormData"];
 
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+ChromeUtils.defineModuleGetter(this, "CreditCard",
+  "resource://gre/modules/CreditCard.jsm");
+
 /**
  * Returns whether the given URL very likely has input
  * fields that contain serialized session store data.
  */
 function isRestorationPage(url) {
   return url == "about:sessionrestore" || url == "about:welcomeback";
 }
 
@@ -30,54 +33,16 @@ function hasRestorationData(data) {
 /**
  * Returns the given document's current URI and strips
  * off the URI's anchor part, if any.
  */
 function getDocumentURI(doc) {
   return doc.documentURI.replace(/#.*$/, "");
 }
 
-/**
- * Returns whether the given value is a valid credit card number based on
- * the Luhn algorithm. See https://en.wikipedia.org/wiki/Luhn_algorithm.
- */
-function isValidCCNumber(value) {
-  // Remove dashes and whitespace.
-  let ccNumber = value.replace(/[-\s]+/g, "");
-
-  // Check for non-alphanumeric characters.
-  if (/[^0-9]/.test(ccNumber)) {
-    return false;
-  }
-
-  // Check for invalid length.
-  let length = ccNumber.length;
-  if (length != 9 && length != 15 && length != 16) {
-    return false;
-  }
-
-  let total = 0;
-  for (let i = 0; i < length; i++) {
-    let currentChar = ccNumber.charAt(length - i - 1);
-    let currentDigit = parseInt(currentChar, 10);
-
-    if (i % 2) {
-      // Double every other value.
-      total += currentDigit * 2;
-      // If the doubled value has two digits, add the digits together.
-      if (currentDigit > 4) {
-        total -= 9;
-      }
-    } else {
-      total += currentDigit;
-    }
-  }
-  return total % 10 == 0;
-}
-
 // For a comprehensive list of all available <INPUT> types see
 // https://dxr.mozilla.org/mozilla-central/search?q=kInputTypeTable&redirect=false
 const IGNORE_PROPERTIES = [
   ["type", new Set(["password", "hidden", "button", "image", "submit", "reset"])],
   ["autocomplete", new Set(["off"])]
 ];
 function shouldIgnoreNode(node) {
   for (let i = 0; i < IGNORE_PROPERTIES.length; ++i) {
@@ -193,19 +158,20 @@ var FormDataInternal = {
 
       // Only generate a limited number of XPath expressions for perf reasons
       // (cf. bug 477564)
       if (!node.id && generatedCount > MAX_TRAVERSED_XPATHS) {
         continue;
       }
 
       // We do not want to collect credit card numbers.
-      if (ChromeUtils.getClassName(node) === "HTMLInputElement" &&
-          isValidCCNumber(node.value)) {
-        continue;
+      if (ChromeUtils.getClassName(node) === "HTMLInputElement") {
+        if (CreditCard.isValidNumber(node.value)) {
+          continue;
+        }
       }
 
       if (ChromeUtils.getClassName(node) === "HTMLInputElement" ||
           ChromeUtils.getClassName(node) === "HTMLTextAreaElement" ||
           (node.namespaceURI == this.namespaceURIs.xul && node.localName == "textbox")) {
         switch (node.type) {
           case "checkbox":
           case "radio":
--- a/toolkit/modules/tests/browser/browser.ini
+++ b/toolkit/modules/tests/browser/browser.ini
@@ -24,16 +24,17 @@ support-files =
   file_script_xhr.js
   head.js
   WebRequest_dynamic.sjs
   WebRequest_redirection.sjs
 
 [browser_AsyncPrefs.js]
 [browser_Battery.js]
 [browser_BrowserUtils.js]
+[browser_CreditCard.js]
 [browser_Deprecated.js]
 [browser_Finder.js]
 [browser_Finder_hidden_textarea.js]
 [browser_Finder_offscreen_text.js]
 [browser_Finder_overflowed_onscreen.js]
 [browser_Finder_overflowed_textarea.js]
 [browser_Finder_pointer_events_none.js]
 [browser_Finder_vertical_text.js]
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/tests/browser/browser_CreditCard.js
@@ -0,0 +1,45 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.import("resource://gre/modules/CreditCard.jsm");
+ChromeUtils.import("resource://formautofill/MasterPassword.jsm");
+
+let oldGetters = {};
+let gFakeLoggedIn = true;
+
+add_task(function setup() {
+  oldGetters._token = Object.getOwnPropertyDescriptor(MasterPassword, "_token").get;
+  oldGetters.isEnabled = Object.getOwnPropertyDescriptor(MasterPassword, "isEnabled").get;
+  oldGetters.isLoggedIn = Object.getOwnPropertyDescriptor(MasterPassword, "isLoggedIn").get;
+  MasterPassword.__defineGetter__("_token", () => { return {hasPassword: true}; });
+  MasterPassword.__defineGetter__("isEnabled", () => true);
+  MasterPassword.__defineGetter__("isLoggedIn", () => gFakeLoggedIn);
+  registerCleanupFunction(() => {
+    MasterPassword.__defineGetter__("_token", oldGetters._token);
+    MasterPassword.__defineGetter__("isEnabled", oldGetters.isEnabled);
+    MasterPassword.__defineGetter__("isLoggedIn", oldGetters.isLoggedIn);
+
+    // CreditCard.jsm and MasterPassword.jsm are imported into the global scope
+    // -- the window -- above. If they're not deleted, they outlive the test and
+    // are reported as a leak.
+    delete window.MasterPassword;
+    delete window.CreditCard;
+  });
+});
+
+add_task(async function test_getLabel_withMasterPassword() {
+  ok(MasterPassword.isEnabled, "Confirm that MasterPassword is faked and thinks it is enabled");
+  ok(MasterPassword.isLoggedIn, "Confirm that MasterPassword is faked and thinks it is logged in");
+
+  const ccNumber = "4111111111111111";
+  const encryptedNumber = await MasterPassword.encrypt(ccNumber);
+  const decryptedNumber = await MasterPassword.decrypt(encryptedNumber);
+  is(decryptedNumber, ccNumber, "Decrypted CC number should match original");
+
+  const name = "Foxkeh";
+  const creditCard = new CreditCard({encryptedNumber, name: "Foxkeh"});
+  const label = await creditCard.getLabel({showNumbers: true});
+  is(label, `${ccNumber}, ${name}`);
+});
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/tests/xpcshell/test_CreditCard.js
@@ -0,0 +1,274 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.import("resource://gre/modules/CreditCard.jsm");
+
+add_task(function isValidNumber() {
+  function testValid(number, shouldPass) {
+    if (shouldPass) {
+      ok(CreditCard.isValidNumber(number), `${number} should be considered valid`);
+    } else {
+      ok(!CreditCard.isValidNumber(number), `${number} should not be considered valid`);
+    }
+  }
+
+  testValid("0000000000000000", true);
+  testValid("4929001587121045", true);
+  testValid("5103059495477870", true);
+  testValid("6011029476355493", true);
+  testValid("3589993783099582", true);
+  testValid("5415425865751454", true);
+  if (CreditCard.isValidNumber("30190729470495")) {
+    ok(false, "todo: 14-digit numbers (Diners Club) aren't supported by isValidNumber yet");
+  }
+  if (CreditCard.isValidNumber("36333851788250")) {
+    ok(false, "todo: 14-digit numbers (Diners Club) aren't supported by isValidNumber yet");
+  }
+  if (CreditCard.isValidNumber("3532596776688495393")) {
+    ok(false, "todo: 19-digit numbers (JCB, Discover, Maestro) could have 16-19 digits");
+  }
+  testValid("5038146897157463", true);
+  testValid("4026313395502338", true);
+  testValid("6387060366272981", true);
+  testValid("474915027480942", true);
+  testValid("924894781317325", true);
+  testValid("714816113937185", true);
+  testValid("790466087343106", true);
+  testValid("474320195408363", true);
+  testValid("219211148122351", true);
+  testValid("633038472250799", true);
+  testValid("354236732906484", true);
+  testValid("095347810189325", true);
+  testValid("930771457288760", true);
+  testValid("3091269135815020", true);
+  testValid("5471839082338112", true);
+  testValid("0580828863575793", true);
+  testValid("5015290610002932", true);
+  testValid("9465714503078607", true);
+  testValid("4302068493801686", true);
+  testValid("2721398408985465", true);
+  testValid("6160334316984331", true);
+  testValid("8643619970075142", true);
+  testValid("0218246069710785", true);
+  testValid("0000-0000-0080-4609", true);
+  testValid("0000 0000 0222 331", true);
+  testValid("344060747836806", true);
+  testValid("001064088", true);
+  testValid("4929001587121046", false);
+  testValid("5103059495477876", false);
+  testValid("6011029476355494", false);
+  testValid("3589993783099581", false);
+  testValid("5415425865751455", false);
+  testValid("5038146897157462", false);
+  testValid("4026313395502336", false);
+  testValid("6387060366272980", false);
+  testValid("344060747836804", false);
+  testValid("30190729470496", false);
+  testValid("36333851788255", false);
+  testValid("526931005800649", false);
+  testValid("724952425140686", false);
+  testValid("379761391174135", false);
+  testValid("030551436468583", false);
+  testValid("947377014076746", false);
+  testValid("254848023655752", false);
+  testValid("226871580283345", false);
+  testValid("708025346034339", false);
+  testValid("917585839076788", false);
+  testValid("918632588027666", false);
+  testValid("9946177098017064", false);
+  testValid("4081194386488872", false);
+  testValid("3095975979578034", false);
+  testValid("3662215692222536", false);
+  testValid("6723210018630429", false);
+  testValid("4411962856225025", false);
+  testValid("8276996369036686", false);
+  testValid("4449796938248871", false);
+  testValid("3350852696538147", false);
+  testValid("5011802870046957", false);
+  testValid("0000", false);
+});
+
+add_task(function test_formatMaskedNumber() {
+  function testFormat(number) {
+    let format = CreditCard.formatMaskedNumber(number);
+    Assert.equal(format.affix, "****", "Affix should always be four asterisks");
+    Assert.equal(format.label, number.substr(-4),
+       "The label should always be the last four digits of the card number");
+  }
+  testFormat("************0000");
+  testFormat("************1045");
+  testFormat("***********6806");
+  testFormat("**********0495");
+  testFormat("**********8250");
+});
+
+add_task(function test_maskNumber() {
+  function testMask(number, expected) {
+    let card = new CreditCard({number});
+    Assert.equal(card.maskedNumber, expected,
+       "Masked number should only show the last four digits");
+  }
+  testMask("0000000000000000", "**** 0000");
+  testMask("4929001587121045", "**** 1045");
+  testMask("5103059495477870", "**** 7870");
+  testMask("6011029476355493", "**** 5493");
+  testMask("3589993783099582", "**** 9582");
+  testMask("5415425865751454", "**** 1454");
+  testMask("344060747836806", "**** 6806");
+  Assert.throws(() => (new CreditCard({number: "1234"})).maskedNumber,
+    /Invalid credit card number/,
+    "Four or less numbers should throw when retrieving the maskedNumber");
+});
+
+add_task(function test_longMaskedNumber() {
+  function testMask(number, expected) {
+    let card = new CreditCard({number});
+    Assert.equal(card.longMaskedNumber, expected,
+       "Long masked number should show asterisks for all digits but last four");
+  }
+  testMask("0000000000000000", "************0000");
+  testMask("4929001587121045", "************1045");
+  testMask("5103059495477870", "************7870");
+  testMask("6011029476355493", "************5493");
+  testMask("3589993783099582", "************9582");
+  testMask("5415425865751454", "************1454");
+  testMask("344060747836806", "***********6806");
+  Assert.throws(() => (new CreditCard({number: "1234"})).longMaskedNumber,
+    /Invalid credit card number/,
+    "Four or less numbers should throw when retrieving the maskedNumber");
+});
+
+add_task(function test_isValid() {
+  function testValid(number, expirationMonth, expirationYear, shouldPass, message) {
+    let card = new CreditCard({
+      number,
+      expirationMonth,
+      expirationYear,
+    });
+    if (shouldPass) {
+      ok(card.isValid(), message);
+    } else {
+      ok(!card.isValid(), message);
+    }
+  }
+  let year = (new Date()).getFullYear();
+  let month = (new Date()).getMonth() + 1;
+
+  testValid("0000000000000000", month, year + 2, true,
+    "Valid number and future expiry date (two years) should pass");
+  testValid("0000000000000000", month + 2, year, true,
+    "Valid number and future expiry date (two months) should pass");
+  testValid("0000000000000000", month, year, true,
+    "Valid number and expiry date equal to this month should pass");
+  testValid("0000000000000000", month - 1, year, false,
+    "Valid number but overdue expiry date should fail");
+  testValid("0000000000000000", month, year - 1, false,
+    "Valid number but overdue expiry date (by a year) should fail");
+  testValid("0000000000000001", month, year + 2, false,
+    "Invalid number but future expiry date should fail");
+});
+
+add_task(function test_normalize() {
+  Assert.equal((new CreditCard({number: "0000 0000 0000 0000"})).number, "0000000000000000",
+    "Spaces should be removed from card number after it is normalized");
+  Assert.equal((new CreditCard({number: "0000   0000\t 0000\t0000"})).number, "0000000000000000",
+    "Spaces should be removed from card number after it is normalized");
+  Assert.equal((new CreditCard({number: "0000-0000-0000-0000"})).number, "0000000000000000",
+    "Hyphens should be removed from card number after it is normalized");
+  Assert.equal((new CreditCard({number: "0000-0000 0000-0000"})).number, "0000000000000000",
+    "Spaces and hyphens should be removed from card number after it is normalized");
+  Assert.equal((new CreditCard({number: "0000000000000000"})).number, "0000000000000000",
+    "Normalized numbers should not get changed");
+  Assert.equal((new CreditCard({number: "0000"})).number, null,
+    "Card numbers that are too short get set to null");
+
+  let card = new CreditCard({number: "0000000000000000"});
+  card.expirationYear = "22";
+  card.expirationMonth = "11";
+  Assert.equal(card.expirationYear, 2022, "Years less than four digits are in the third millenium");
+  card.expirationYear = "-200";
+  ok(isNaN(card.expirationYear), "Negative years are blocked");
+  card.expirationYear = "1998";
+  Assert.equal(card.expirationYear, 1998, "Years with four digits are not changed");
+  card.expirationYear = "test";
+  ok(isNaN(card.expirationYear), "non-number years are returned as NaN");
+  card.expirationMonth = "02";
+  Assert.equal(card.expirationMonth, 2, "Zero-leading months are converted properly (not octal)");
+  card.expirationMonth = "test";
+  ok(isNaN(card.expirationMonth), "non-number months are returned as NaN");
+  card.expirationMonth = "12";
+  Assert.equal(card.expirationMonth, 12, "Months formatted correctly are unchanged");
+  card.expirationMonth = "13";
+  ok(isNaN(card.expirationMonth), "Months above 12 are blocked");
+  card.expirationMonth = "7";
+  Assert.equal(card.expirationMonth, 7, "Changing back to a valid number passes");
+  card.expirationMonth = "0";
+  ok(isNaN(card.expirationMonth), "Months below 1 are blocked");
+
+  card.expirationMonth = card.expirationYear = undefined;
+  card.expirationString = "2022/01";
+  Assert.equal(card.expirationMonth, 1, "Month should be parsed correctly");
+  Assert.equal(card.expirationYear, 2022, "Year should be parsed correctly");
+  card.expirationString = "2023-02";
+  Assert.equal(card.expirationMonth, 2, "Month should be parsed correctly");
+  Assert.equal(card.expirationYear, 2023, "Year should be parsed correctly");
+  card.expirationString = "03-2024";
+  Assert.equal(card.expirationMonth, 3, "Month should be parsed correctly");
+  Assert.equal(card.expirationYear, 2024, "Year should be parsed correctly");
+  card.expirationString = "04/2025";
+  Assert.equal(card.expirationMonth, 4, "Month should be parsed correctly");
+  Assert.equal(card.expirationYear, 2025, "Year should be parsed correctly");
+  card.expirationString = "05/26";
+  Assert.equal(card.expirationMonth, 5, "Month should be parsed correctly");
+  Assert.equal(card.expirationYear, 2026, "Year should be parsed correctly");
+  card.expirationString = "27-6";
+  Assert.equal(card.expirationMonth, 6, "Month should be parsed correctly");
+  Assert.equal(card.expirationYear, 2027, "Year should be parsed correctly");
+  card.expirationString = "07/11";
+  Assert.equal(card.expirationMonth, 7, "Ambiguous month should be parsed correctly");
+  Assert.equal(card.expirationYear, 2011, "Ambiguous year should be parsed correctly");
+
+  card = new CreditCard({
+    number: "0000000000000000",
+    expirationMonth: "02",
+    expirationYear: "2112",
+    expirationString: "06-2066",
+  });
+  Assert.equal(card.expirationMonth, 2, "expirationString is takes lower precendence than explicit month");
+  Assert.equal(card.expirationYear, 2112, "expirationString is takes lower precendence than explicit year");
+});
+
+add_task(async function test_label() {
+  let testCases = [{
+      number: "0000000000000000",
+      name: "Rudy Badoody",
+      expectedLabel: "0000000000000000, Rudy Badoody",
+      expectedMaskedLabel: "**** 0000, Rudy Badoody",
+    }, {
+      number: "3589993783099582",
+      name: "Jimmy Babimmy",
+      expectedLabel: "3589993783099582, Jimmy Babimmy",
+      expectedMaskedLabel: "**** 9582, Jimmy Babimmy",
+    }, {
+      number: "************9582",
+      name: "Jimmy Babimmy",
+      expectedLabel: "**** 9582, Jimmy Babimmy",
+      expectedMaskedLabel: "**** 9582, Jimmy Babimmy",
+    }, {
+      name: "Ricky Bobby",
+      expectedLabel: "Ricky Bobby",
+      expectedMaskedLabel: "Ricky Bobby",
+  }];
+
+  for (let testCase of testCases) {
+    let {number, name} = testCase;
+    let card = new CreditCard({number, name});
+
+    Assert.equal(await card.getLabel({showNumbers: true}), testCase.expectedLabel,
+       "The expectedLabel should be shown when showNumbers is true");
+    Assert.equal(await card.getLabel({showNumbers: false}), testCase.expectedMaskedLabel,
+       "The expectedMaskedLabel should be shown when showNumbers is false");
+  }
+});
--- a/toolkit/modules/tests/xpcshell/xpcshell.ini
+++ b/toolkit/modules/tests/xpcshell/xpcshell.ini
@@ -8,16 +8,17 @@ support-files =
   zips/zen.zip
 
 [test_BinarySearch.js]
 skip-if = toolkit == 'android'
 [test_CanonicalJSON.js]
 [test_client_id.js]
 skip-if = toolkit == 'android'
 [test_Color.js]
+[test_CreditCard.js]
 [test_DeferredTask.js]
 skip-if = toolkit == 'android'
 [test_FileUtils.js]
 skip-if = toolkit == 'android'
 [test_FinderIterator.js]
 [test_GMPInstallManager.js]
 skip-if = toolkit == 'android'
 [test_Http.js]