Add payment PAN generators and fix populated label state

This commit is contained in:
J8k3 2026-04-25 14:20:08 -04:00
parent 1f8297643f
commit 83cdab4553
8 changed files with 527 additions and 2 deletions

View File

@ -101,6 +101,8 @@ Important assumptions:
## 5) Generate / Verify Card Validation Data ## 5) Generate / Verify Card Validation Data
Operations: Operations:
- `Generate Test PAN`
- `Parse PAN`
- `Generate Card Validation Data` - `Generate Card Validation Data`
- `Verify Card Validation Data` - `Verify Card Validation Data`
@ -117,6 +119,15 @@ Important assumptions:
- iCVV forces service code `999` - iCVV forces service code `999`
- this is a clear-key software emulation of common card-validation flows - 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 ## 6) Generate / Translate / Verify Payment PIN Data
Operations: Operations:
- `Generate Payment PIN Data` - `Generate Payment PIN Data`
@ -273,7 +284,19 @@ Flow:
- keep issuer validation data, PAN, PVKI, decimalization table, and PVK in the args - 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 - 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: Operations:
- `Generate AS2805 KEK Validation` - `Generate AS2805 KEK Validation`
- `Calculate Payment KCV` - `Calculate Payment KCV`

View File

@ -40,6 +40,8 @@ This list targets software-only development and testing environments.
4. Session derivation input normalization checks. 4. Session derivation input normalization checks.
5. Cryptogram preimage assembly validation recipes. 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. 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 ## AWS Payment Cryptography Candidate Recipes
1. `EncryptData` and `DecryptData` parity vectors for AES, TDES, and RSA. 1. `EncryptData` and `DecryptData` parity vectors for AES, TDES, and RSA.

View File

@ -579,6 +579,8 @@
"Verify EMV ARQC", "Verify EMV ARQC",
"Generate EMV ARPC", "Generate EMV ARPC",
"Generate EMV MAC For PIN Change", "Generate EMV MAC For PIN Change",
"Generate Test PAN",
"Parse PAN",
"Generate Card Validation Data", "Generate Card Validation Data",
"Verify Card Validation Data", "Verify Card Validation Data",
"Generate Payment PIN Data", "Generate Payment PIN Data",

288
src/core/lib/Pan.mjs Normal file
View File

@ -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,
};

View File

@ -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.<br><br><b>Input:</b> ignored.<br><b>Arguments:</b> 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.<br><br>This operation is intended for recipe chaining into card-validation, PIN, EMV, and parser flows.";
this.inlineHelp = "<strong>Input:</strong> ignored.<br><strong>Args:</strong> 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;

View File

@ -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.<br><br><b>Input:</b> PAN digits.<br><b>Arguments:</b> none.<br><br>This parser identifies Visa, Mastercard, American Express, and Discover based on public prefix and length rules, and reports Luhn validity.";
this.inlineHelp = "<strong>Input:</strong> PAN digits only.<br><strong>Args:</strong> 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;

View File

@ -225,6 +225,9 @@ class RecipeWaiter {
*/ */
ingChange(e) { ingChange(e) {
if (e && e?.target?.classList?.contains("no-state-change")) return; 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); window.dispatchEvent(this.manager.statechange);
} }
@ -553,6 +556,8 @@ class RecipeWaiter {
} else { } else {
ingEls[i].value = args[i]; ingEls[i].value = args[i];
} }
this.syncArgVisualState(ingEls[i]);
} }
this.triggerArgEvents(op); 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. * Handler for operationadd events.
* *
@ -832,6 +861,7 @@ class RecipeWaiter {
if (text) { if (text) {
targ.value = text; targ.value = text;
this.syncArgVisualState(targ);
return; return;
} }
@ -840,7 +870,7 @@ class RecipeWaiter {
const self = this; const self = this;
reader.onload = function (e) { reader.onload = function (e) {
targ.value = e.target.result; targ.value = e.target.result;
// Trigger floating label move self.syncArgVisualState(targ);
const changeEvent = new Event("change"); const changeEvent = new Event("change");
targ.dispatchEvent(changeEvent); targ.dispatchEvent(changeEvent);
window.dispatchEvent(self.manager.statechange); window.dispatchEvent(self.manager.statechange);

View File

@ -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", name: "Verify Card Validation Data: known CVV2 sample",
input: "0123456789ABCDEFFEDCBA9876543210", input: "0123456789ABCDEFFEDCBA9876543210",