diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index 0047d1de28ee..c8d250df509a 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -20,6 +20,7 @@ import { import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types"; +import { parseYearMonthExpiry } from "@bitwarden/common/autofill/utils"; import { NeverDomains } from "@bitwarden/common/models/domain/domain-service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { @@ -1898,11 +1899,21 @@ export class OverlayBackground implements OverlayBackgroundInterface { const cardView = new CardView(); cardView.cardholderName = card.cardholderName || ""; cardView.number = card.number || ""; - cardView.expMonth = card.expirationMonth || ""; - cardView.expYear = card.expirationYear || ""; cardView.code = card.cvv || ""; cardView.brand = card.number ? CardView.getCardBrandByPatterns(card.number) : ""; + // If there's a combined expiration date value and no individual month or year values, + // try to parse them from the combined value + if (card.expirationDate && !card.expirationMonth && !card.expirationYear) { + const [parsedYear, parsedMonth] = parseYearMonthExpiry(card.expirationDate); + + cardView.expMonth = parsedMonth || ""; + cardView.expYear = parsedYear || ""; + } else { + cardView.expMonth = card.expirationMonth || ""; + cardView.expYear = card.expirationYear || ""; + } + const cipherView = new CipherView(); cipherView.name = ""; cipherView.folderId = null; diff --git a/apps/browser/src/autofill/services/autofill-constants.ts b/apps/browser/src/autofill/services/autofill-constants.ts index 9cf2b6848c60..c379daaf2d83 100644 --- a/apps/browser/src/autofill/services/autofill-constants.ts +++ b/apps/browser/src/autofill/services/autofill-constants.ts @@ -300,8 +300,6 @@ export class CreditCardAutoFillConstants { "cb-type", ]; - static readonly CardExpiryDateDelimiters: string[] = ["/", "-", ".", " "]; - // Note, these are expressions of user-guidance for the expected expiry date format to be used static readonly CardExpiryDateFormats: CardExpiryDateFormat[] = [ // English diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index 49d00624f34c..5d9bfa9f9d43 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -6,11 +6,15 @@ import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; +import { + AutofillOverlayVisibility, + CardExpiryDateDelimiters, +} from "@bitwarden/common/autofill/constants"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service"; import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types"; +import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EventType } from "@bitwarden/common/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -30,7 +34,6 @@ import { CardView } from "@bitwarden/common/vault/models/view/card.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FieldView } from "@bitwarden/common/vault/models/view/field.view"; import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view"; -import { normalizeExpiryYearFormat } from "@bitwarden/common/vault/utils"; import { BrowserApi } from "../../platform/browser/browser-api"; import { ScriptInjectorService } from "../../platform/services/abstractions/script-injector.service"; @@ -1397,8 +1400,7 @@ export default class AutofillService implements AutofillServiceInterface { if (expectedExpiryDateFormat) { const { Month, MonthShort, Year } = expiryDateFormatPatterns; - const expiryDateDelimitersPattern = - "\\" + CreditCardAutoFillConstants.CardExpiryDateDelimiters.join("\\"); + const expiryDateDelimitersPattern = "\\" + CardExpiryDateDelimiters.join("\\"); // assign the delimiter from the expected format string delimiter = @@ -1450,8 +1452,7 @@ export default class AutofillService implements AutofillServiceInterface { let expectedDateFormat = null; let dateFormatPatterns = null; - const expiryDateDelimitersPattern = - "\\" + CreditCardAutoFillConstants.CardExpiryDateDelimiters.join("\\"); + const expiryDateDelimitersPattern = "\\" + CardExpiryDateDelimiters.join("\\"); CreditCardAutoFillConstants.CardExpiryDateFormats.find((dateFormat) => { dateFormatPatterns = dateFormat; @@ -1489,6 +1490,8 @@ export default class AutofillService implements AutofillServiceInterface { return false; }); }); + // @TODO if expectedDateFormat is still null, and there is a `pattern` attribute, cycle + // through generated formatted values, checking against the provided regex pattern return [expectedDateFormat, dateFormatPatterns]; } diff --git a/apps/browser/src/vault/popup/components/vault/add-edit.component.ts b/apps/browser/src/vault/popup/components/vault/add-edit.component.ts index 02654f37efe7..de8e5615e2ff 100644 --- a/apps/browser/src/vault/popup/components/vault/add-edit.component.ts +++ b/apps/browser/src/vault/popup/components/vault/add-edit.component.ts @@ -12,6 +12,7 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -23,7 +24,6 @@ import { CollectionService } from "@bitwarden/common/vault/abstractions/collecti import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; -import { normalizeExpiryYearFormat } from "@bitwarden/common/vault/utils"; import { DialogService } from "@bitwarden/components"; import { PasswordRepromptService } from "@bitwarden/vault"; diff --git a/apps/web/src/app/vault/individual-vault/add-edit.component.ts b/apps/web/src/app/vault/individual-vault/add-edit.component.ts index d1b51b611f53..9826d9f2f5ad 100644 --- a/apps/web/src/app/vault/individual-vault/add-edit.component.ts +++ b/apps/web/src/app/vault/individual-vault/add-edit.component.ts @@ -8,6 +8,7 @@ import { EventCollectionService } from "@bitwarden/common/abstractions/event/eve import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { isCardExpired } from "@bitwarden/common/autofill/utils"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { ProductTierType } from "@bitwarden/common/billing/enums"; import { EventType } from "@bitwarden/common/enums"; @@ -24,7 +25,6 @@ import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folde import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { Launchable } from "@bitwarden/common/vault/interfaces/launchable"; -import { isCardExpired } from "@bitwarden/common/vault/utils"; import { DialogService } from "@bitwarden/components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { PasswordRepromptService } from "@bitwarden/vault"; diff --git a/libs/angular/src/vault/components/add-edit.component.ts b/libs/angular/src/vault/components/add-edit.component.ts index 255d553a3ece..21a7b35ac519 100644 --- a/libs/angular/src/vault/components/add-edit.component.ts +++ b/libs/angular/src/vault/components/add-edit.component.ts @@ -12,6 +12,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli import { OrganizationUserStatusType, PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils"; import { EventType } from "@bitwarden/common/enums"; import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @@ -36,7 +37,6 @@ import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view" import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view"; -import { normalizeExpiryYearFormat } from "@bitwarden/common/vault/utils"; import { DialogService } from "@bitwarden/components"; import { PasswordRepromptService } from "@bitwarden/vault"; diff --git a/libs/common/src/autofill/constants/index.ts b/libs/common/src/autofill/constants/index.ts index 15005691d295..4ccec81a4478 100644 --- a/libs/common/src/autofill/constants/index.ts +++ b/libs/common/src/autofill/constants/index.ts @@ -109,3 +109,5 @@ export type ExtensionCommandType = (typeof ExtensionCommand)[keyof typeof Extens export const CLEAR_NOTIFICATION_LOGIN_DATA_DURATION = 60 * 1000; // 1 minute export const MAX_DEEP_QUERY_RECURSION_DEPTH = 4; + +export * from "./match-patterns"; diff --git a/libs/common/src/autofill/constants/match-patterns.ts b/libs/common/src/autofill/constants/match-patterns.ts new file mode 100644 index 000000000000..f756537d28d2 --- /dev/null +++ b/libs/common/src/autofill/constants/match-patterns.ts @@ -0,0 +1,26 @@ +export const CardExpiryDateDelimiters: string[] = ["/", "-", ".", " "]; + +// `CardExpiryDateDelimiters` is not intended solely for regex consumption, +// so we need to format it here +export const ExpiryDateDelimitersPattern = + "\\" + + CardExpiryDateDelimiters.join("\\") + // replace space character with the regex whitespace character class + .replace(" ", "s"); + +export const MonthPattern = "(([1]{1}[0-2]{1})|(0?[1-9]{1}))"; + +// Because we're dealing with expiry dates, we assume the year will be in current or next century (as of 2024) +export const ExpiryFullYearPattern = "2[0-1]{1}\\d{2}"; + +export const DelimiterPatternExpression = new RegExp(`[${ExpiryDateDelimitersPattern}]`, "g"); + +export const IrrelevantExpiryCharactersPatternExpression = new RegExp( + // "nor digits" to ensure numbers are removed from guidance pattern, which aren't covered by ^\w + `[^\\d${ExpiryDateDelimitersPattern}]`, + "g", +); + +export const MonthPatternExpression = new RegExp(`^${MonthPattern}$`); + +export const ExpiryFullYearPatternExpression = new RegExp(`^${ExpiryFullYearPattern}$`); diff --git a/libs/common/src/autofill/utils.spec.ts b/libs/common/src/autofill/utils.spec.ts new file mode 100644 index 000000000000..b09dc723b8e0 --- /dev/null +++ b/libs/common/src/autofill/utils.spec.ts @@ -0,0 +1,284 @@ +import { + normalizeExpiryYearFormat, + isCardExpired, + parseYearMonthExpiry, +} from "@bitwarden/common/autofill/utils"; +import { CardView } from "@bitwarden/common/vault/models/view/card.view"; + +function getExpiryYearValueFormats(currentCentury: string) { + return [ + [-12, `${currentCentury}12`], + [0, `${currentCentury}00`], + [2043, "2043"], // valid year with a length of four should be taken directly + [24, `${currentCentury}24`], + [3054, "3054"], // valid year with a length of four should be taken directly + [31423524543, `${currentCentury}43`], + [4, `${currentCentury}04`], + [null, null], + [undefined, null], + ["-12", `${currentCentury}12`], + ["", null], + ["0", `${currentCentury}00`], + ["00", `${currentCentury}00`], + ["000", `${currentCentury}00`], + ["0000", `${currentCentury}00`], + ["00000", `${currentCentury}00`], + ["0234234", `${currentCentury}34`], + ["04", `${currentCentury}04`], + ["2043", "2043"], // valid year with a length of four should be taken directly + ["24", `${currentCentury}24`], + ["3054", "3054"], // valid year with a length of four should be taken directly + ["31423524543", `${currentCentury}43`], + ["4", `${currentCentury}04`], + ["aaaa", null], + ["adgshsfhjsdrtyhsrth", null], + ["agdredg42grg35grrr. ea3534@#^145345ag$%^ -_#$rdg ", `${currentCentury}45`], + ]; +} + +describe("normalizeExpiryYearFormat", () => { + const currentCentury = `${new Date().getFullYear()}`.slice(0, 2); + + const expiryYearValueFormats = getExpiryYearValueFormats(currentCentury); + + expiryYearValueFormats.forEach(([inputValue, expectedValue]) => { + it(`should return '${expectedValue}' when '${inputValue}' is passed`, () => { + const formattedValue = normalizeExpiryYearFormat(inputValue); + + expect(formattedValue).toEqual(expectedValue); + }); + }); + + describe("in the year 3107", () => { + const theDistantFuture = new Date(Date.UTC(3107, 1, 1)); + jest.spyOn(Date, "now").mockReturnValue(theDistantFuture.valueOf()); + + beforeAll(() => { + jest.useFakeTimers({ advanceTimers: true }); + jest.setSystemTime(theDistantFuture); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + const currentCentury = `${new Date(Date.now()).getFullYear()}`.slice(0, 2); + expect(currentCentury).toBe("31"); + + const expiryYearValueFormats = getExpiryYearValueFormats(currentCentury); + + expiryYearValueFormats.forEach(([inputValue, expectedValue]) => { + it(`should return '${expectedValue}' when '${inputValue}' is passed`, () => { + const formattedValue = normalizeExpiryYearFormat(inputValue); + + expect(formattedValue).toEqual(expectedValue); + }); + }); + jest.clearAllTimers(); + }); +}); + +function getCardExpiryDateValues() { + const currentDate = new Date(); + + const currentYear = currentDate.getFullYear(); + + // `Date` months are zero-indexed, our expiry date month inputs are one-indexed + const currentMonth = currentDate.getMonth() + 1; + + return [ + [null, null, false], // no month, no year + [undefined, undefined, false], // no month, no year, invalid values + ["", "", false], // no month, no year, invalid values + ["12", "agdredg42grg35grrr. ea3534@#^145345ag$%^ -_#$rdg ", false], // invalid values + ["0", `${currentYear}`, true], // invalid month + ["0", `${currentYear - 1}`, true], // invalid 0 month + ["00", `${currentYear + 1}`, false], // invalid 0 month + [`${currentMonth}`, "0000", true], // current month, in the year 2000 + [null, `${currentYear}`.slice(-2), false], // no month, this year + [null, `${currentYear - 1}`.slice(-2), true], // no month, last year + ["1", null, false], // no year, January + ["1", `${currentYear - 1}`, true], // January last year + ["13", `${currentYear}`, false], // 12 + 1 is Feb. in the next year (Date is zero-indexed) + [`${currentMonth + 36}`, `${currentYear - 1}`, true], // even though the month value would put the date 3 years into the future when calculated with `Date`, an explicit year in the past indicates the card is expired + [`${currentMonth}`, `${currentYear}`, false], // this year, this month (not expired until the month is over) + [`${currentMonth}`, `${currentYear}`.slice(-2), false], // This month, this year (not expired until the month is over) + [`${currentMonth - 1}`, `${currentYear}`, true], // last month + [`${currentMonth - 1}`, `${currentYear + 1}`, false], // 11 months from now + ]; +} + +describe("isCardExpired", () => { + const expiryYearValueFormats = getCardExpiryDateValues(); + + expiryYearValueFormats.forEach( + ([inputMonth, inputYear, expectedValue]: [string | null, string | null, boolean]) => { + it(`should return ${expectedValue} when the card expiry month is ${inputMonth} and the card expiry year is ${inputYear}`, () => { + const testCardView = new CardView(); + testCardView.expMonth = inputMonth; + testCardView.expYear = inputYear; + + const cardIsExpired = isCardExpired(testCardView); + + expect(cardIsExpired).toBe(expectedValue); + }); + }, + ); +}); + +const combinedDateTestValues = [ + " 2024 / 05 ", + "05 2024", + "05 2024", // Tab whitespace character + "05 2024", // Em Quad + "05 2024", // Em Space + "05 2024", // En Quad + "05 2024", // En Space + "05 2024", // Figure Space + "05 2024", // Four-Per-Em Space + "05 2024", // Hair Space + "05 2024", // Ideographic Space + "05 2024", // Medium Mathematical Space + "05 2024", // No-Break Space + "05 2024", // ogham space mark + "05 2024", // Punctuation Space + "05 2024", // Six-Per-Em Space + "05 2024", // Thin Space + "05 2024", // Three-Per-Em Space + "05 24", + "05-2024", + "05-24", + "05.2024", + "05.24", + "05/2024", + "05/24", + "052024", + "0524", + "2024 05", + "2024 5", + "2024-05", + "2024-5", + "2024.05", + "2024.5", + "2024/05", + "2024/5", + "202405", + "20245", + "24 05", + "24 5", + "24-05", + "24-5", + "24.05", + "24.5", + "24/05", + "24/5", + "2405", + "5 2024", + "5 24", + "5-2024", + "5-24", + "5.2024", + "5.24", + "5/2024", + "5/24", + "52024", +]; +const expectedParsedValue = ["2024", "5"]; +describe("parseYearMonthExpiry", () => { + it('returns "null" expiration year and month values when a value of "" is passed', () => { + expect(parseYearMonthExpiry("")).toStrictEqual([null, null]); + }); + + it('returns "null" expiration year and month values when a value of "/" is passed', () => { + expect(parseYearMonthExpiry("/")).toStrictEqual([null, null]); + }); + + combinedDateTestValues.forEach((combinedDate) => { + it(`returns an expiration year value of "${expectedParsedValue[0]}" and month value of "${expectedParsedValue[1]}" when a value of "${combinedDate}" is passed`, () => { + expect(parseYearMonthExpiry(combinedDate)).toStrictEqual(expectedParsedValue); + }); + }); + + it('returns an expiration year value of "2002" and month value of "2" when a value of "022" is passed', () => { + expect(parseYearMonthExpiry("022")).toStrictEqual(["2002", "2"]); + }); + + it('returns an expiration year value of "2002" and month value of "2" when a value of "202" is passed', () => { + expect(parseYearMonthExpiry("202")).toStrictEqual(["2002", "2"]); + }); + + it('returns an expiration year value of "2002" and month value of "1" when a value of "1/2/3/4" is passed', () => { + expect(parseYearMonthExpiry("1/2/3/4")).toStrictEqual(["2002", "1"]); + }); + + it('returns valid expiration year and month values when a value of "198" is passed', () => { + // This static value will cause the test to fail in 2098 + const testValue = "198"; + const parsedValue = parseYearMonthExpiry(testValue); + + expect(parsedValue[0]).toHaveLength(4); + expect(parsedValue[1]).toMatch(/^[\d]{1,2}$/); + + expect(parsedValue).toStrictEqual(["2098", "1"]); + }); + + // Ambiguous input cases: we use try/catch for these cases as a workaround to accept either + // outcome (both are valid interpretations) in the event of any future code changes. + describe("ambiguous input cases", () => { + it('returns valid expiration year and month values when a value of "111" is passed', () => { + const testValue = "111"; + const parsedValue = parseYearMonthExpiry(testValue); + + expect(parsedValue[0]).toHaveLength(4); + expect(parsedValue[1]).toMatch(/^[\d]{1,2}$/); + + try { + expect(parsedValue).toStrictEqual(["2011", "1"]); + } catch { + expect(parsedValue).toStrictEqual(["2001", "11"]); + } + }); + + it('returns valid expiration year and month values when a value of "212" is passed', () => { + const testValue = "212"; + const parsedValue = parseYearMonthExpiry(testValue); + + expect(parsedValue[0]).toHaveLength(4); + expect(parsedValue[1]).toMatch(/^[\d]{1,2}$/); + + try { + expect(parsedValue).toStrictEqual(["2012", "2"]); + } catch { + expect(parsedValue).toStrictEqual(["2021", "2"]); + } + }); + + it('returns valid expiration year and month values when a value of "245" is passed', () => { + const testValue = "245"; + const parsedValue = parseYearMonthExpiry(testValue); + + expect(parsedValue[0]).toHaveLength(4); + expect(parsedValue[1]).toMatch(/^[\d]{1,2}$/); + + try { + expect(parsedValue).toStrictEqual(["2045", "2"]); + } catch { + expect(parsedValue).toStrictEqual(["2024", "5"]); + } + }); + + it('returns valid expiration year and month values when a value of "524" is passed', () => { + const testValue = "524"; + const parsedValue = parseYearMonthExpiry(testValue); + + expect(parsedValue[0]).toHaveLength(4); + expect(parsedValue[1]).toMatch(/^[\d]{1,2}$/); + + try { + expect(parsedValue).toStrictEqual(["2024", "5"]); + } catch { + expect(parsedValue).toStrictEqual(["2052", "4"]); + } + }); + }); +}); diff --git a/libs/common/src/autofill/utils.ts b/libs/common/src/autofill/utils.ts new file mode 100644 index 000000000000..86411691ea21 --- /dev/null +++ b/libs/common/src/autofill/utils.ts @@ -0,0 +1,307 @@ +import { + DelimiterPatternExpression, + ExpiryFullYearPattern, + ExpiryFullYearPatternExpression, + IrrelevantExpiryCharactersPatternExpression, + MonthPatternExpression, +} from "@bitwarden/common/autofill/constants"; +import { CardView } from "@bitwarden/common/vault/models/view/card.view"; + +type NonZeroIntegers = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; +type Year = `${NonZeroIntegers}${NonZeroIntegers}${0 | NonZeroIntegers}${0 | NonZeroIntegers}`; + +/** + * Takes a string or number value and returns a string value formatted as a valid 4-digit year + * + * @param {(string | number)} yearInput + * @return {*} {(Year | null)} + */ +export function normalizeExpiryYearFormat(yearInput: string | number): Year | null { + // The input[type="number"] is returning a number, convert it to a string + // An empty field returns null, avoid casting `"null"` to a string + const yearInputIsEmpty = yearInput == null || yearInput === ""; + let expirationYear = yearInputIsEmpty ? null : `${yearInput}`; + + // Exit early if year is already formatted correctly or empty + if (yearInputIsEmpty || /^[1-9]{1}\d{3}$/.test(expirationYear)) { + return expirationYear as Year; + } + + expirationYear = expirationYear + // For safety, because even input[type="number"] will allow decimals + .replace(/[^\d]/g, "") + // remove any leading zero padding (leave the last leading zero if it ends the string) + .replace(/^[0]+(?=.)/, ""); + + if (expirationYear === "") { + expirationYear = null; + } + + // given the context of payment card expiry, a year character length of 3, or over 4 + // is more likely to be a mistake than an intentional value for the far past or far future. + if (expirationYear && expirationYear.length !== 4) { + const paddedYear = ("00" + expirationYear).slice(-2); + const currentCentury = `${new Date().getFullYear()}`.slice(0, 2); + + expirationYear = currentCentury + paddedYear; + } + + return expirationYear as Year | null; +} + +/** + * Takes a cipher card view and returns "true" if the month and year affirmativey indicate + * the card is expired. + * + * @param {CardView} cipherCard + * @return {*} {boolean} + */ +export function isCardExpired(cipherCard: CardView): boolean { + if (cipherCard) { + const { expMonth = null, expYear = null } = cipherCard; + + const now = new Date(); + const normalizedYear = normalizeExpiryYearFormat(expYear); + + // If the card year is before the current year, don't bother checking the month + if (normalizedYear && parseInt(normalizedYear, 10) < now.getFullYear()) { + return true; + } + + if (normalizedYear && expMonth) { + const parsedMonthInteger = parseInt(expMonth, 10); + + const parsedMonth = isNaN(parsedMonthInteger) + ? 0 + : // Add a month floor of 0 to protect against an invalid low month value of "0" or negative integers + Math.max( + // `Date` months are zero-indexed + parsedMonthInteger - 1, + 0, + ); + + const parsedYear = parseInt(normalizedYear, 10); + + // First day of the next month minus one, to get last day of the card month + const cardExpiry = new Date(parsedYear, parsedMonth + 1, 0); + + return cardExpiry < now; + } + } + + return false; +} + +/** + * Attempt to split a string into date segments on the basis of expected formats and delimiter symbols. + * + * @param {string} combinedExpiryValue + * @return {*} {string[]} + */ +function splitCombinedDateValues(combinedExpiryValue: string): string[] { + let sanitizedValue = combinedExpiryValue + .replace(IrrelevantExpiryCharactersPatternExpression, "") + .trim(); + + // Do this after initial value replace to avoid identifying leading whitespace as delimiter + const parsedDelimiter = sanitizedValue.match(DelimiterPatternExpression)?.[0] || null; + + let dateParts = [sanitizedValue]; + + if (parsedDelimiter?.length) { + // If the parsed delimiter is a whitespace character, assign 's' (character class) instead + const delimiterPattern = /\s/.test(parsedDelimiter) ? "\\s" : "\\" + parsedDelimiter; + + sanitizedValue = sanitizedValue + // Remove all other delimiter characters not identified as the delimiter + .replace(new RegExp(`[^\\d${delimiterPattern}]`, "g"), "") + // Also de-dupe the delimiter character + .replace(new RegExp(`[${delimiterPattern}]{2,}`, "g"), parsedDelimiter); + + dateParts = sanitizedValue.split(parsedDelimiter); + } + + return ( + dateParts + // remove values that have no length + .filter((splitValue) => splitValue?.length) + ); +} + +/** + * Given an array of split card expiry date parts, + * returns an array of those values ordered by year then month + * + * @param {string[]} splitDateInput + * @return {*} {([string | null, string | null])} + */ +function parseDelimitedYearMonthExpiry([firstPart, secondPart]: string[]): [string, string] { + // Conditionals here are structured to avoid unnecessary evaluations and are ordered + // from more authoritative checks to checks yielding increasingly inferred conclusions + + // If a 4-digit value is found (when there are multiple parts), it can't be month + if (ExpiryFullYearPatternExpression.test(firstPart)) { + return [firstPart, secondPart]; + } + + // If a 4-digit value is found (when there are multiple parts), it can't be month + if (ExpiryFullYearPatternExpression.test(secondPart)) { + return [secondPart, firstPart]; + } + + // If it's a two digit value that doesn't match against month pattern, assume it's a year + if (/\d{2}/.test(firstPart) && !MonthPatternExpression.test(firstPart)) { + return [firstPart, secondPart]; + } + + // If it's a two digit value that doesn't match against month pattern, assume it's a year + if (/\d{2}/.test(secondPart) && !MonthPatternExpression.test(secondPart)) { + return [secondPart, firstPart]; + } + + // Values are too ambiguous (e.g. "12/09"). For the most part, + // a month-looking value likely is, at the time of writing (year 2024). + let parsedYear = firstPart; + let parsedMonth = secondPart; + + if (MonthPatternExpression.test(firstPart)) { + parsedYear = secondPart; + parsedMonth = firstPart; + } + + return [parsedYear, parsedMonth]; +} + +/** + * Given a single string of integers, attempts to identify card expiry date portions within + * and return values ordered by year then month + * + * @param {string} dateInput + * @return {*} {([string | null, string | null])} + */ +function parseNonDelimitedYearMonthExpiry(dateInput: string): [string | null, string | null] { + if (dateInput.length > 4) { + // e.g. + // "052024" + // "202405" + // "20245" + // "52024" + + // If the value is over 5-characters long, it likely has a full year format in it + const [parsedYear, parsedMonth] = dateInput + .split(new RegExp(`(?=${ExpiryFullYearPattern})|(?<=${ExpiryFullYearPattern})`, "g")) + .sort((current: string, next: string) => (current.length > next.length ? -1 : 1)); + + return [parsedYear, parsedMonth]; + } + + if (dateInput.length === 4) { + // e.g. + // "0524" + // "2405" + + // If the `sanitizedFirstPart` value is a length of 4, it must be split in half, since + // neither a year or month will be represented with three characters + const splitFirstPartFirstHalf = dateInput.slice(0, 2); + const splitFirstPartSecondHalf = dateInput.slice(-2); + + let parsedYear = splitFirstPartSecondHalf; + let parsedMonth = splitFirstPartFirstHalf; + + // If the first part doesn't match a month pattern, assume it's a year + if (!MonthPatternExpression.test(splitFirstPartFirstHalf)) { + parsedYear = splitFirstPartFirstHalf; + parsedMonth = splitFirstPartSecondHalf; + } + + return [parsedYear, parsedMonth]; + } + + // e.g. + // "245" + // "202" + // "212" + // "022" + // "111" + + // A valid year representation here must be two characters so try to find it first. + + let parsedYear = null; + let parsedMonth = null; + + // Split if there is a digit with a leading zero + const splitFirstPartOnLeadingZero = dateInput.split(/(?<=0[1-9]{1})|(?=0[1-9]{1})/); + + // Assume a leading zero indicates a month in ambiguous cases (e.g. "202"), since we're + // dealing with expiry dates and the next two-digit year with a leading zero will be 2100 + if (splitFirstPartOnLeadingZero.length > 1) { + parsedYear = splitFirstPartOnLeadingZero[0]; + parsedMonth = splitFirstPartOnLeadingZero[1]; + + if (splitFirstPartOnLeadingZero[0].startsWith("0")) { + parsedMonth = splitFirstPartOnLeadingZero[0]; + parsedYear = splitFirstPartOnLeadingZero[1]; + } + } else { + // Here, a year has to be two-digits, and a month can't be more than one, so assume the first two digits that are greater than the current year is the year representation. + parsedYear = dateInput.slice(0, 2); + parsedMonth = dateInput.slice(-1); + + const currentYear = new Date().getFullYear(); + const normalizedParsedYear = parseInt(normalizeExpiryYearFormat(parsedYear), 10); + const normalizedParsedYearAlternative = parseInt( + normalizeExpiryYearFormat(dateInput.slice(-2)), + 10, + ); + + if (normalizedParsedYear < currentYear && normalizedParsedYearAlternative >= currentYear) { + parsedYear = dateInput.slice(-2); + parsedMonth = dateInput.slice(0, 1); + } + } + + return [parsedYear, parsedMonth]; +} + +/** + * Attempt to parse year and month parts of a combined expiry date value. + * + * @param {string} combinedExpiryValue + * @return {*} {([string | null, string | null])} + */ +export function parseYearMonthExpiry(combinedExpiryValue: string): [Year | null, string | null] { + let parsedYear = null; + let parsedMonth = null; + + const dateParts = splitCombinedDateValues(combinedExpiryValue); + + if (dateParts.length < 1) { + return [null, null]; + } + + const sanitizedFirstPart = + dateParts[0]?.replace(IrrelevantExpiryCharactersPatternExpression, "") || ""; + const sanitizedSecondPart = + dateParts[1]?.replace(IrrelevantExpiryCharactersPatternExpression, "") || ""; + + // If there is only one date part, no delimiter was found in the passed value + if (dateParts.length === 1) { + [parsedYear, parsedMonth] = parseNonDelimitedYearMonthExpiry(sanitizedFirstPart); + } + // There are multiple date parts + else { + [parsedYear, parsedMonth] = parseDelimitedYearMonthExpiry([ + sanitizedFirstPart, + sanitizedSecondPart, + ]); + } + + const normalizedParsedYear = normalizeExpiryYearFormat(parsedYear); + const normalizedParsedMonth = parsedMonth?.replace(/^0+/, "").slice(0, 2); + + // Set "empty" values to null + parsedYear = normalizedParsedYear?.length ? normalizedParsedYear : null; + parsedMonth = normalizedParsedMonth?.length ? normalizedParsedMonth : null; + + return [parsedYear, parsedMonth]; +} diff --git a/libs/common/src/vault/models/view/card.view.ts b/libs/common/src/vault/models/view/card.view.ts index f3bf4e1fab24..fad10851e6a4 100644 --- a/libs/common/src/vault/models/view/card.view.ts +++ b/libs/common/src/vault/models/view/card.view.ts @@ -1,8 +1,8 @@ import { Jsonify } from "type-fest"; +import { normalizeExpiryYearFormat } from "../../../autofill/utils"; import { CardLinkedId as LinkedId } from "../../enums"; import { linkedFieldOption } from "../../linked-field-option.decorator"; -import { normalizeExpiryYearFormat } from "../../utils"; import { ItemView } from "./item.view"; diff --git a/libs/common/src/vault/utils.spec.ts b/libs/common/src/vault/utils.spec.ts deleted file mode 100644 index 54ec66984e2d..000000000000 --- a/libs/common/src/vault/utils.spec.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { CardView } from "@bitwarden/common/vault/models/view/card.view"; -import { normalizeExpiryYearFormat, isCardExpired } from "@bitwarden/common/vault/utils"; - -function getExpiryYearValueFormats(currentCentury: string) { - return [ - [-12, `${currentCentury}12`], - [0, `${currentCentury}00`], - [2043, "2043"], // valid year with a length of four should be taken directly - [24, `${currentCentury}24`], - [3054, "3054"], // valid year with a length of four should be taken directly - [31423524543, `${currentCentury}43`], - [4, `${currentCentury}04`], - [null, null], - [undefined, null], - ["-12", `${currentCentury}12`], - ["", null], - ["0", `${currentCentury}00`], - ["00", `${currentCentury}00`], - ["000", `${currentCentury}00`], - ["0000", `${currentCentury}00`], - ["00000", `${currentCentury}00`], - ["0234234", `${currentCentury}34`], - ["04", `${currentCentury}04`], - ["2043", "2043"], // valid year with a length of four should be taken directly - ["24", `${currentCentury}24`], - ["3054", "3054"], // valid year with a length of four should be taken directly - ["31423524543", `${currentCentury}43`], - ["4", `${currentCentury}04`], - ["aaaa", null], - ["adgshsfhjsdrtyhsrth", null], - ["agdredg42grg35grrr. ea3534@#^145345ag$%^ -_#$rdg ", `${currentCentury}45`], - ]; -} - -describe("normalizeExpiryYearFormat", () => { - const currentCentury = `${new Date().getFullYear()}`.slice(0, 2); - - const expiryYearValueFormats = getExpiryYearValueFormats(currentCentury); - - expiryYearValueFormats.forEach(([inputValue, expectedValue]) => { - it(`should return '${expectedValue}' when '${inputValue}' is passed`, () => { - const formattedValue = normalizeExpiryYearFormat(inputValue); - - expect(formattedValue).toEqual(expectedValue); - }); - }); - - describe("in the year 3107", () => { - const theDistantFuture = new Date(Date.UTC(3107, 1, 1)); - jest.spyOn(Date, "now").mockReturnValue(theDistantFuture.valueOf()); - - beforeAll(() => { - jest.useFakeTimers({ advanceTimers: true }); - jest.setSystemTime(theDistantFuture); - }); - - afterAll(() => { - jest.useRealTimers(); - }); - - const currentCentury = `${new Date(Date.now()).getFullYear()}`.slice(0, 2); - expect(currentCentury).toBe("31"); - - const expiryYearValueFormats = getExpiryYearValueFormats(currentCentury); - - expiryYearValueFormats.forEach(([inputValue, expectedValue]) => { - it(`should return '${expectedValue}' when '${inputValue}' is passed`, () => { - const formattedValue = normalizeExpiryYearFormat(inputValue); - - expect(formattedValue).toEqual(expectedValue); - }); - }); - jest.clearAllTimers(); - }); -}); - -function getCardExpiryDateValues() { - const currentDate = new Date(); - - const currentYear = currentDate.getFullYear(); - - // `Date` months are zero-indexed, our expiry date month inputs are one-indexed - const currentMonth = currentDate.getMonth() + 1; - - return [ - [null, null, false], // no month, no year - [undefined, undefined, false], // no month, no year, invalid values - ["", "", false], // no month, no year, invalid values - ["12", "agdredg42grg35grrr. ea3534@#^145345ag$%^ -_#$rdg ", false], // invalid values - ["0", `${currentYear - 1}`, true], // invalid 0 month - ["00", `${currentYear + 1}`, false], // invalid 0 month - [`${currentMonth}`, "0000", true], // current month, in the year 2000 - [null, `${currentYear}`.slice(-2), false], // no month, this year - [null, `${currentYear - 1}`.slice(-2), true], // no month, last year - ["1", null, false], // no year, January - ["1", `${currentYear - 1}`, true], // January last year - ["13", `${currentYear}`, false], // 12 + 1 is Feb. in the next year (Date is zero-indexed) - [`${currentMonth + 36}`, `${currentYear - 1}`, true], // even though the month value would put the date 3 years into the future when calculated with `Date`, an explicit year in the past indicates the card is expired - [`${currentMonth}`, `${currentYear}`, false], // this year, this month (not expired until the month is over) - [`${currentMonth}`, `${currentYear}`.slice(-2), false], // This month, this year (not expired until the month is over) - [`${currentMonth - 1}`, `${currentYear}`, true], // last month - [`${currentMonth - 1}`, `${currentYear + 1}`, false], // 11 months from now - ]; -} - -describe("isCardExpired", () => { - const expiryYearValueFormats = getCardExpiryDateValues(); - - expiryYearValueFormats.forEach( - ([inputMonth, inputYear, expectedValue]: [string | null, string | null, boolean]) => { - it(`should return ${expectedValue} when the card expiry month is ${inputMonth} and the card expiry year is ${inputYear}`, () => { - const testCardView = new CardView(); - testCardView.expMonth = inputMonth; - testCardView.expYear = inputYear; - - const cardIsExpired = isCardExpired(testCardView); - - expect(cardIsExpired).toBe(expectedValue); - }); - }, - ); -}); diff --git a/libs/common/src/vault/utils.ts b/libs/common/src/vault/utils.ts deleted file mode 100644 index 7d8784eda78a..000000000000 --- a/libs/common/src/vault/utils.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { CardView } from "@bitwarden/common/vault/models/view/card.view"; - -type NonZeroIntegers = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; -type Year = `${NonZeroIntegers}${NonZeroIntegers}${0 | NonZeroIntegers}${0 | NonZeroIntegers}`; - -/** - * Takes a string or number value and returns a string value formatted as a valid 4-digit year - * - * @export - * @param {(string | number)} yearInput - * @return {*} {(Year | null)} - */ -export function normalizeExpiryYearFormat(yearInput: string | number): Year | null { - // The input[type="number"] is returning a number, convert it to a string - // An empty field returns null, avoid casting `"null"` to a string - const yearInputIsEmpty = yearInput == null || yearInput === ""; - let expirationYear = yearInputIsEmpty ? null : `${yearInput}`; - - // Exit early if year is already formatted correctly or empty - if (yearInputIsEmpty || /^[1-9]{1}\d{3}$/.test(expirationYear)) { - return expirationYear as Year; - } - - expirationYear = expirationYear - // For safety, because even input[type="number"] will allow decimals - .replace(/[^\d]/g, "") - // remove any leading zero padding (leave the last leading zero if it ends the string) - .replace(/^[0]+(?=.)/, ""); - - if (expirationYear === "") { - expirationYear = null; - } - - // given the context of payment card expiry, a year character length of 3, or over 4 - // is more likely to be a mistake than an intentional value for the far past or far future. - if (expirationYear && expirationYear.length !== 4) { - const paddedYear = ("00" + expirationYear).slice(-2); - const currentCentury = `${new Date().getFullYear()}`.slice(0, 2); - - expirationYear = currentCentury + paddedYear; - } - - return expirationYear as Year | null; -} - -/** - * Takes a cipher card view and returns "true" if the month and year affirmativey indicate - * the card is expired. - * - * @export - * @param {CardView} cipherCard - * @return {*} {boolean} - */ -export function isCardExpired(cipherCard: CardView): boolean { - if (cipherCard) { - const { expMonth = null, expYear = null } = cipherCard; - - const now = new Date(); - const normalizedYear = normalizeExpiryYearFormat(expYear); - - // If the card year is before the current year, don't bother checking the month - if (normalizedYear && parseInt(normalizedYear) < now.getFullYear()) { - return true; - } - - if (normalizedYear && expMonth) { - // `Date` months are zero-indexed - const parsedMonth = - parseInt(expMonth) - 1 || - // Add a month floor of 0 to protect against an invalid low month value of "0" - 0; - - const parsedYear = parseInt(normalizedYear); - - // First day of the next month minus one, to get last day of the card month - const cardExpiry = new Date(parsedYear, parsedMonth + 1, 0); - - return cardExpiry < now; - } - } - - return false; -} diff --git a/libs/importer/src/importers/base-importer.ts b/libs/importer/src/importers/base-importer.ts index 215210eda140..9cba62c5fafc 100644 --- a/libs/importer/src/importers/base-importer.ts +++ b/libs/importer/src/importers/base-importer.ts @@ -1,5 +1,6 @@ import * as papa from "papaparse"; +import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; @@ -11,7 +12,6 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view"; -import { normalizeExpiryYearFormat } from "@bitwarden/common/vault/utils"; import { ImportResult } from "../models/import-result"; diff --git a/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.ts b/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.ts index e1ef3dc0f37e..bc4ff608805e 100644 --- a/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.ts @@ -5,11 +5,11 @@ import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils"; import { EventType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CardView } from "@bitwarden/common/vault/models/view/card.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { normalizeExpiryYearFormat } from "@bitwarden/common/vault/utils"; import { CardComponent, FormFieldModule, diff --git a/libs/vault/src/cipher-view/cipher-view.component.ts b/libs/vault/src/cipher-view/cipher-view.component.ts index cadf388e76dc..e28f7f2a2bb5 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.ts +++ b/libs/vault/src/cipher-view/cipher-view.component.ts @@ -5,13 +5,13 @@ import { firstValueFrom, Observable, Subject, takeUntil } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { isCardExpired } from "@bitwarden/common/autofill/utils"; import { CollectionId } from "@bitwarden/common/types/guid"; import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; -import { isCardExpired } from "@bitwarden/common/vault/utils"; import { CalloutModule, SearchModule } from "@bitwarden/components"; import { AdditionalOptionsComponent } from "./additional-options/additional-options.component";