Add payment PAN generators and fix populated label state
This commit is contained in:
parent
1f8297643f
commit
83cdab4553
@ -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`
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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",
|
||||
|
||||
288
src/core/lib/Pan.mjs
Normal file
288
src/core/lib/Pan.mjs
Normal 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,
|
||||
};
|
||||
74
src/core/operations/GenerateTestPAN.mjs
Normal file
74
src/core/operations/GenerateTestPAN.mjs
Normal 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;
|
||||
44
src/core/operations/ParsePAN.mjs
Normal file
44
src/core/operations/ParsePAN.mjs
Normal 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;
|
||||
@ -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);
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user