diff --git a/PAYMENT_RECIPES.md b/PAYMENT_RECIPES.md index cacd7866..35741b52 100644 --- a/PAYMENT_RECIPES.md +++ b/PAYMENT_RECIPES.md @@ -101,6 +101,8 @@ Important assumptions: ## 5) Generate / Verify Card Validation Data Operations: +- `Generate Test PAN` +- `Parse PAN` - `Generate Card Validation Data` - `Verify Card Validation Data` @@ -117,6 +119,15 @@ Important assumptions: - iCVV forces service code `999` - this is a clear-key software emulation of common card-validation flows +Recommended chain: +- `Generate Test PAN` -> `Parse PAN` -> `Generate Card Validation Data` + +Use `Generate Test PAN` when: +- you want a Visa, Mastercard, American Express, or Discover PAN to feed into later recipes + +Use `Parse PAN` when: +- you want to confirm network, IIN, length, and Luhn validity before continuing + ## 6) Generate / Translate / Verify Payment PIN Data Operations: - `Generate Payment PIN Data` @@ -273,7 +284,19 @@ Flow: - keep issuer validation data, PAN, PVKI, decimalization table, and PVK in the args - use the JSON output when you need to inspect how the verification artifact was assembled -## H) AS2805 KEK Validation +## H) Brand Test Card Setup +Operations: +- `Generate Test PAN` +- `Parse PAN` +- `Generate Card Validation Data` +- `Generate Payment PIN Data` + +Flow: +- generate a curated or locally generated brand-valid PAN +- parse it to confirm brand and Luhn validity +- feed the PAN into CVV, PIN, EMV, or parser recipes + +## I) AS2805 KEK Validation Operations: - `Generate AS2805 KEK Validation` - `Calculate Payment KCV` diff --git a/PAYMENT_SIM_RECIPES.md b/PAYMENT_SIM_RECIPES.md index 42778bb0..b9f36593 100644 --- a/PAYMENT_SIM_RECIPES.md +++ b/PAYMENT_SIM_RECIPES.md @@ -40,6 +40,8 @@ This list targets software-only development and testing environments. 4. Session derivation input normalization checks. 5. Cryptogram preimage assembly validation recipes. 6. PAN parser and network classifier recipes for Visa (`4`, typically 13/16/19 digits), Mastercard (`51`-`55`, `2221`-`2720`, 16 digits), American Express (`34`, `37`, 15 digits), and Discover (`6011`, `644`-`649`, `65`, and `622126`-`622925`, typically 16-19 digits), including Luhn validation and issuer-range explanation. +Status: +`Generate Test PAN` and `Parse PAN` are now implemented. Remaining follow-on work is richer test-card-profile generation around expiry, CVV, service code, AVS, and EMV context. ## AWS Payment Cryptography Candidate Recipes 1. `EncryptData` and `DecryptData` parity vectors for AES, TDES, and RSA. diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index ff16bebc..6167b11f 100644 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -579,6 +579,8 @@ "Verify EMV ARQC", "Generate EMV ARPC", "Generate EMV MAC For PIN Change", + "Generate Test PAN", + "Parse PAN", "Generate Card Validation Data", "Verify Card Validation Data", "Generate Payment PIN Data", diff --git a/src/core/lib/Pan.mjs b/src/core/lib/Pan.mjs new file mode 100644 index 00000000..008e7a54 --- /dev/null +++ b/src/core/lib/Pan.mjs @@ -0,0 +1,288 @@ +/** + * @license Apache-2.0 + */ + +import OperationError from "../errors/OperationError.mjs"; + +const PAN_BRANDS = ["Visa", "Mastercard", "American Express", "Discover"]; + +const PAN_BRAND_RULES = { + "Visa": { + lengths: [13, 16, 19], + curatedPan: "4024140000000131", + curatedSource: "Public Visa test PAN published in Mastercard AVS scenario documentation.", + prefixes: [ + { + start: 4, + end: 4, + lengths: [13, 16, 19], + description: "Visa cards begin with 4." + } + ] + }, + "Mastercard": { + lengths: [16], + curatedPan: "5204749999994311", + curatedSource: "Public Mastercard test PAN published in Mastercard AVS scenario documentation.", + prefixes: [ + { + start: 51, + end: 55, + lengths: [16], + description: "Mastercard 2-series legacy range 51 through 55." + }, + { + start: 2221, + end: 2720, + lengths: [16], + description: "Mastercard 2-series range 2221 through 2720." + } + ] + }, + "American Express": { + lengths: [15], + curatedPan: "371449635398431", + curatedSource: "Representative Amex-style test PAN included as a deterministic sample because no openly published public Amex network sample was verified here.", + prefixes: [ + { + start: 34, + end: 34, + lengths: [15], + description: "American Express cards begin with 34 or 37 and use 15 digits." + }, + { + start: 37, + end: 37, + lengths: [15], + description: "American Express cards begin with 34 or 37 and use 15 digits." + } + ] + }, + "Discover": { + lengths: [16, 17, 18, 19], + curatedPan: "6011000991543426", + curatedSource: "Public Discover POS test PAN published by Discover Global Network.", + prefixes: [ + { + start: 6011, + end: 6011, + lengths: [16, 17, 18, 19], + description: "Discover range 6011." + }, + { + start: 644, + end: 649, + lengths: [16, 17, 18, 19], + description: "Discover range 644 through 649." + }, + { + start: 65, + end: 65, + lengths: [16, 17, 18, 19], + description: "Discover range 65." + }, + { + start: 622126, + end: 622925, + lengths: [16, 17, 18, 19], + description: "Discover range 622126 through 622925." + } + ] + } +}; + +/** + * Normalizes a PAN. + * + * @param {string} pan + * @returns {string} + */ +function normalizePan(pan) { + const normalized = (pan || "").replace(/\s+/g, ""); + if (!/^\d{12,19}$/.test(normalized)) { + throw new OperationError("PAN must be 12 to 19 digits."); + } + return normalized; +} + +/** + * Calculates a Luhn check digit for a numeric body. + * + * @param {string} body + * @returns {number} + */ +function luhnCheckDigit(body) { + let sum = 0; + let doubleDigit = true; + + for (let i = body.length - 1; i >= 0; i--) { + let digit = parseInt(body.charAt(i), 10); + if (doubleDigit) { + digit *= 2; + if (digit > 9) digit -= 9; + } + sum += digit; + doubleDigit = !doubleDigit; + } + + return (10 - (sum % 10)) % 10; +} + +/** + * Returns whether a full PAN passes Luhn validation. + * + * @param {string} pan + * @returns {boolean} + */ +function isLuhnValid(pan) { + const normalized = normalizePan(pan); + const body = normalized.slice(0, -1); + return luhnCheckDigit(body) === parseInt(normalized.slice(-1), 10); +} + +/** + * Returns the first matching brand rule for a PAN. + * + * @param {string} pan + * @returns {{brand: string, rule: Object}|null} + */ +function matchPanBrand(pan) { + for (const brand of PAN_BRANDS) { + const config = PAN_BRAND_RULES[brand]; + for (const rule of config.prefixes) { + const prefixLength = String(rule.start).length; + if (!rule.lengths.includes(pan.length)) continue; + const prefix = parseInt(pan.substring(0, prefixLength), 10); + if (prefix >= rule.start && prefix <= rule.end) { + return { brand, rule }; + } + } + } + + return null; +} + +/** + * Parses a PAN and returns payment-network details. + * + * @param {string} pan + * @returns {Object} + */ +function parsePan(pan) { + const normalized = normalizePan(pan); + const match = matchPanBrand(normalized); + + return { + pan: normalized, + network: match ? match.brand : "Unknown", + majorIndustryIdentifier: normalized.charAt(0), + issuerIdentificationNumber: normalized.substring(0, Math.min(8, normalized.length)), + length: normalized.length, + luhnValid: isLuhnValid(normalized), + matchedRule: match ? { + rangeStart: String(match.rule.start), + rangeEnd: String(match.rule.end), + lengths: match.rule.lengths, + description: match.rule.description + } : null + }; +} + +/** + * Appends a Luhn check digit to a numeric PAN body. + * + * @param {string} body + * @returns {string} + */ +function finalizePan(body) { + return `${body}${luhnCheckDigit(body)}`; +} + +/** + * Generates a numeric filler string. + * + * @param {number} length + * @returns {string} + */ +function fillerDigits(length) { + const seed = "12345678901234567890"; + return seed.repeat(Math.ceil(length / seed.length)).substring(0, length); +} + +/** + * Generates a deterministic brand-valid PAN. + * + * @param {string} brand + * @param {number} requestedLength + * @returns {{pan: string, prefixDescription: string}} + */ +function generateBrandPan(brand, requestedLength) { + const config = PAN_BRAND_RULES[brand]; + if (!config) { + throw new OperationError("Unsupported payment network."); + } + + const length = config.lengths.includes(requestedLength) ? requestedLength : config.lengths[0]; + let selectedRule = config.prefixes[0]; + + if (brand === "Mastercard" && length === 16) { + selectedRule = config.prefixes[1]; + } else if (brand === "American Express") { + selectedRule = config.prefixes[1]; + } else if (brand === "Discover") { + selectedRule = config.prefixes[0]; + } + + const prefix = String(selectedRule.start); + const bodyLength = length - 1; + const body = `${prefix}${fillerDigits(bodyLength - prefix.length)}`.substring(0, bodyLength); + + return { + pan: finalizePan(body), + prefixDescription: selectedRule.description + }; +} + +/** + * Generates a test PAN. + * + * @param {string} brand + * @param {string} mode + * @param {number} length + * @returns {Object} + */ +function generateTestPan(brand, mode, length) { + const config = PAN_BRAND_RULES[brand]; + if (!config) { + throw new OperationError("Unsupported payment network."); + } + + if (mode === "Curated sample") { + const parsed = parsePan(config.curatedPan); + return { + brand, + mode, + pan: config.curatedPan, + source: config.curatedSource, + ...parsed + }; + } + + const generated = generateBrandPan(brand, Number(length) || config.lengths[0]); + const parsed = parsePan(generated.pan); + return { + brand, + mode, + pan: generated.pan, + source: "Generated locally from public network prefix and length rules, then Luhn-completed.", + generationRule: generated.prefixDescription, + ...parsed + }; +} + +export { + PAN_BRANDS, + generateTestPan, + isLuhnValid, + parsePan, +}; diff --git a/src/core/operations/GenerateTestPAN.mjs b/src/core/operations/GenerateTestPAN.mjs new file mode 100644 index 00000000..6f10b73c --- /dev/null +++ b/src/core/operations/GenerateTestPAN.mjs @@ -0,0 +1,74 @@ +/** + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; +import { PAN_BRANDS, generateTestPan } from "../lib/Pan.mjs"; + +/** + * Generate test PAN operation. + */ +class GenerateTestPAN extends Operation { + /** + * GenerateTestPAN constructor. + */ + constructor() { + super(); + + this.name = "Generate Test PAN"; + this.module = "Payment"; + this.description = "Generate a brand-valid payment card number for test workflows.

Input: ignored.
Arguments: choose the payment network, decide whether to use a curated sample or a locally generated brand-valid PAN, and choose the target length when the network supports multiple lengths.

This operation is intended for recipe chaining into card-validation, PIN, EMV, and parser flows."; + this.inlineHelp = "Input: ignored.
Args: choose the network, sample mode, and target length."; + this.testDataSamples = [ + { + name: "Visa curated sample", + input: "", + args: ["Visa", "Curated sample", 16, true] + } + ]; + this.infoURL = "https://en.wikipedia.org/wiki/Payment_card_number"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + name: "Network", + type: "option", + value: PAN_BRANDS, + comment: "Choose the payment network whose public numbering rules should be applied." + }, + { + name: "Sample mode", + type: "option", + value: ["Curated sample", "Generated valid PAN"], + comment: "Curated sample returns a fixed network sample when available. Generated mode builds a deterministic network-valid PAN from public prefix and length rules and then applies Luhn." + }, + { + name: "Target length", + type: "number", + value: 16, + min: 13, + max: 19, + comment: "Used only in generated mode. Networks that do not support the requested length fall back to their first supported length." + }, + { + name: "Output as JSON", + type: "boolean", + value: true, + comment: "When enabled, returns the PAN plus the detected network, IIN, Luhn status, and source note." + } + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [brand, mode, length, outputJson] = args; + const result = generateTestPan(brand, mode, length); + return outputJson ? JSON.stringify(result, null, 4) : result.pan; + } +} + +export default GenerateTestPAN; diff --git a/src/core/operations/ParsePAN.mjs b/src/core/operations/ParsePAN.mjs new file mode 100644 index 00000000..17a0ac55 --- /dev/null +++ b/src/core/operations/ParsePAN.mjs @@ -0,0 +1,44 @@ +/** + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; +import { parsePan } from "../lib/Pan.mjs"; + +/** + * Parse PAN operation. + */ +class ParsePAN extends Operation { + /** + * ParsePAN constructor. + */ + constructor() { + super(); + + this.name = "Parse PAN"; + this.module = "Payment"; + this.description = "Paste a payment card number into the input field and classify it by public network rules.

Input: PAN digits.
Arguments: none.

This parser identifies Visa, Mastercard, American Express, and Discover based on public prefix and length rules, and reports Luhn validity."; + this.inlineHelp = "Input: PAN digits only.
Args: none."; + this.testDataSamples = [ + { + name: "Discover sample", + input: "6011000991543426", + args: [] + } + ]; + this.infoURL = "https://en.wikipedia.org/wiki/Payment_card_number"; + this.inputType = "string"; + this.outputType = "string"; + this.args = []; + } + + /** + * @param {string} input + * @returns {string} + */ + run(input) { + return JSON.stringify(parsePan(input), null, 4); + } +} + +export default ParsePAN; diff --git a/src/web/waiters/RecipeWaiter.mjs b/src/web/waiters/RecipeWaiter.mjs index 5267ad43..fc770f59 100755 --- a/src/web/waiters/RecipeWaiter.mjs +++ b/src/web/waiters/RecipeWaiter.mjs @@ -225,6 +225,9 @@ class RecipeWaiter { */ ingChange(e) { if (e && e?.target?.classList?.contains("no-state-change")) return; + if (e?.target?.classList?.contains("arg")) { + this.syncArgVisualState(e.target); + } window.dispatchEvent(this.manager.statechange); } @@ -553,6 +556,8 @@ class RecipeWaiter { } else { ingEls[i].value = args[i]; } + + this.syncArgVisualState(ingEls[i]); } this.triggerArgEvents(op); @@ -753,6 +758,30 @@ class RecipeWaiter { } + /** + * Keeps floating-label state in sync for programmatically populated args. + * + * @param {HTMLElement} el + */ + syncArgVisualState(el) { + if (!el) return; + + const group = el.closest(".form-group, .bmd-form-group"); + if (!group) return; + + let isFilled = false; + if (el.getAttribute("type") === "checkbox" || el.getAttribute("type") === "radio") { + isFilled = el.checked; + } else if (typeof el.value === "string") { + isFilled = el.value.trim().length > 0; + } else { + isFilled = Boolean(el.value); + } + + group.classList.toggle("is-filled", isFilled); + } + + /** * Handler for operationadd events. * @@ -832,6 +861,7 @@ class RecipeWaiter { if (text) { targ.value = text; + this.syncArgVisualState(targ); return; } @@ -840,7 +870,7 @@ class RecipeWaiter { const self = this; reader.onload = function (e) { targ.value = e.target.result; - // Trigger floating label move + self.syncArgVisualState(targ); const changeEvent = new Event("change"); targ.dispatchEvent(changeEvent); window.dispatchEvent(self.manager.statechange); diff --git a/tests/operations/tests/Payment.mjs b/tests/operations/tests/Payment.mjs index 3edb6846..a4cfc0b8 100644 --- a/tests/operations/tests/Payment.mjs +++ b/tests/operations/tests/Payment.mjs @@ -209,6 +209,68 @@ TestRegister.addTests([ } ] }, + { + name: "Generate Test PAN: Visa curated sample", + input: "", + expectedOutput: JSON.stringify({ + brand: "Visa", + mode: "Curated sample", + pan: "4024140000000131", + source: "Public Visa test PAN published in Mastercard AVS scenario documentation.", + network: "Visa", + majorIndustryIdentifier: "4", + issuerIdentificationNumber: "40241400", + length: 16, + luhnValid: true, + matchedRule: { + rangeStart: "4", + rangeEnd: "4", + lengths: [13, 16, 19], + description: "Visa cards begin with 4." + } + }, null, 4), + recipeConfig: [ + { + op: "Generate Test PAN", + args: ["Visa", "Curated sample", 16, true] + } + ] + }, + { + name: "Generate Test PAN: American Express generated sample", + input: "", + expectedOutput: "371234567890120", + recipeConfig: [ + { + op: "Generate Test PAN", + args: ["American Express", "Generated valid PAN", 15, false] + } + ] + }, + { + name: "Parse PAN: Discover sample", + input: "6011000991543426", + expectedOutput: JSON.stringify({ + pan: "6011000991543426", + network: "Discover", + majorIndustryIdentifier: "6", + issuerIdentificationNumber: "60110009", + length: 16, + luhnValid: true, + matchedRule: { + rangeStart: "6011", + rangeEnd: "6011", + lengths: [16, 17, 18, 19], + description: "Discover range 6011." + } + }, null, 4), + recipeConfig: [ + { + op: "Parse PAN", + args: [] + } + ] + }, { name: "Verify Card Validation Data: known CVV2 sample", input: "0123456789ABCDEFFEDCBA9876543210",