toolkit/modules/CreditCard.jsm
author Sam Foster <sfoster@mozilla.com>
Thu, 11 Oct 2018 23:54:25 +0000
changeset 440806 31313cac4517c54061fe8207a965492cdde9b564
parent 440656 9605a2bb0c596ed1badeead50ebd3f47d8378d6f
child 441174 b6abd17c078bae35faac3aa50682a7f6a107d490
permissions -rw-r--r--
Bug 1485105 - Allow 12-19 digit length card numbers. r=MattN * Change to isValidNumber to allow any number length in the range. This also removes 9 as a valid payment card number length * Amend form autocomplete test for sensitive 9 digit numbers. We no longer consider them valid cc numbers, test for 19 digit numbers instead * Fix intermittent issue in a session restore test. It turns out Date.now().toString() can sometimes pass the Luhn algorithm and look like a valid credit card number. I believe this could lead to it being treated as sensitive data which is not saved and restored, failing the test Differential Revision: https://phabricator.services.mozilla.com/D8271

/* 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");

// The list of known and supported credit card network ids ("types")
// This list mirrors the networks from dom/payments/BasicCardPayment.cpp
// and is defined by https://www.w3.org/Payments/card-network-ids
const SUPPORTED_NETWORKS = Object.freeze([
  "amex",
  "cartebancaire",
  "diners",
  "discover",
  "jcb",
  "mastercard",
  "mir",
  "unionpay",
  "visa",
]);

class CreditCard {
  /**
   * @param {string} name
   * @param {string} number
   * @param {string} expirationString
   * @param {string|number} expirationMonth
   * @param {string|number} expirationYear
   * @param {string} network
   * @param {string|number} ccv
   * @param {string} encryptedNumber
   */
  constructor({
    name,
    number,
    expirationString,
    expirationMonth,
    expirationYear,
    network,
    ccv,
    encryptedNumber,
  }) {
    this._name = name;
    this._unmodifiedNumber = number;
    this._encryptedNumber = encryptedNumber;
    this._ccv = ccv;
    this.number = number;
    // Only prefer the string version if missing one or both parsed formats.
    if (expirationString && (!expirationMonth || !expirationYear)) {
      this.expirationString = expirationString;
    } else {
      this.expirationMonth = expirationMonth;
      this.expirationYear = expirationYear;
    }
    if (network) {
      this.network = network;
    }
  }

  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
      // 12 digits (Maestro).
      // [1] https://en.wikipedia.org/wiki/Payment_card_number
      normalizedNumber = normalizedNumber.match(/^\d{12,}$/) ?
        normalizedNumber : null;
      this._number = normalizedNumber;
    }
  }

  get network() {
    return this._network;
  }

  set network(value) {
    this._network = value || undefined;
  }

  // Implements the Luhn checksum algorithm as described at
  // http://wikipedia.org/wiki/Luhn_algorithm
  // Number digit lengths vary with network, but should fall within 12-19 range. [2]
  // More details at https://en.wikipedia.org/wiki/Payment_card_number
  isValidNumber() {
    if (!this._number) {
      return false;
    }

    // Remove dashes and whitespace
    let number = this._number.replace(/[\-\s]/g, "");

    let len = number.length;
    if (len < 12 || len > 19) {
      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 undefined;
    }
    return month;
  }

  _normalizeExpirationYear(year) {
    year = parseInt(year, 10);
    if (isNaN(year) || year < 0) {
      return undefined;
    }
    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: undefined, year: undefined};
  }

  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();
  }

  static isValidNetwork(network) {
    return SUPPORTED_NETWORKS.includes(network);
  }
}
CreditCard.SUPPORTED_NETWORKS = SUPPORTED_NETWORKS;