diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index 6052e9fb..e0052a54 100644 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -599,8 +599,6 @@ "EMV Verify MAC", "HSM Parse Futurex Command", "HSM Parse Thales Command", - "PIN IBM 3624 Offset Generate", - "PIN IBM 3624 Verify", "Key Generate", "MAC Generate", "MAC Verify", @@ -615,6 +613,9 @@ "PIN Block Translate", "PIN Data Generate", "PIN Data Verify", + "PIN Generate", + "PIN IBM 3624 Offset Generate", + "PIN IBM 3624 Verify", "TR-31 Parse Key Block", "TR-34 Parse Key Transport", "VISA PVV Generate", diff --git a/src/core/operations/GeneratePIN.mjs b/src/core/operations/GeneratePIN.mjs new file mode 100644 index 00000000..afe656b0 --- /dev/null +++ b/src/core/operations/GeneratePIN.mjs @@ -0,0 +1,125 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import OperationError from "../errors/OperationError.mjs"; +import { buildPinBlock } from "../lib/PinBlock.mjs"; + +const PIN_OUTPUT_MODES = [ + "PIN digits", + "ISO Format 0 clear PIN block", + "ISO Format 1 clear PIN block", + "ISO Format 3 clear PIN block", +]; + +// Maps output mode label → PIN_BLOCK_FORMATS string used by buildPinBlock +const OUTPUT_TO_FORMAT = { + "ISO Format 0 clear PIN block": "ISO Format 0", + "ISO Format 1 clear PIN block": "ISO Format 1", + "ISO Format 3 clear PIN block": "ISO Format 3", +}; + +/** + * Generate PIN operation. + */ +class GeneratePIN extends Operation { + /** + * GeneratePIN constructor. + */ + constructor() { + super(); + + this.name = "PIN Generate"; + this.module = "Payment"; + this.description = "Generate a cryptographically random cardholder PIN and optionally encode it as a clear ISO 9564 PIN block for use in test recipes.

Input: ignored.
Arguments: choose the PIN length, the output mode, and (for block modes) the PAN.

The PIN digits are drawn using crypto.getRandomValues with rejection sampling to guarantee uniform distribution across 0–9.

Block output modes produce the clear (unencrypted) PIN block directly; these are test artifacts and must not be treated as production PIN blocks.

Security: Test data only. Do not use generated PINs or clear PIN blocks in production systems."; + this.inlineHelp = "Input: ignored.
Args: PIN length, output mode, and PAN for block formats.
Validation: uniform random digits via crypto.getRandomValues; clear ISO 9564 block formats 0, 1, and 3."; + this.testDataSamples = [ + { + name: "4-digit PIN, digits only", + input: "", + args: [4, "PIN digits", ""] + }, + { + name: "4-digit PIN, Format 0 block", + input: "", + args: [4, "ISO Format 0 clear PIN block", "5432101234567890"] + } + ]; + this.infoURL = "https://en.wikipedia.org/wiki/Personal_identification_number"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + name: "PIN length", + type: "number", + value: 4, + min: 4, + max: 12, + comment: "Number of PIN digits to generate. Most cardholder PINs are 4 digits." + }, + { + name: "Output", + type: "option", + value: PIN_OUTPUT_MODES, + comment: "PIN digits only, or a clear ISO 9564 PIN block. Block modes require the PAN argument." + }, + { + name: "PAN (for block formats)", + type: "string", + value: "", + comment: "Required for ISO Format 0 and Format 3 block output. Ignored for PIN digits and ISO Format 1." + } + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [length, outputMode, pan] = args; + + if (!Number.isInteger(length) || length < 4 || length > 12) { + throw new OperationError("PIN length must be between 4 and 12."); + } + + const pin = generateRandomPin(length); + + if (outputMode === "PIN digits") return pin; + + const format = OUTPUT_TO_FORMAT[outputMode]; + return buildPinBlock(format, pin, pan, true); + } +} + +/** + * Generates a single random decimal digit using rejection sampling. + * Rejects values >= 250 to ensure uniform distribution across 0–9 + * (250 = 25 × 10, so bytes 0–249 map to exactly 25 values per digit). + * + * @returns {number} + */ +function randomDecimalDigit() { + const buf = new Uint8Array(1); + let b; + do { + globalThis.crypto.getRandomValues(buf); + b = buf[0]; + } while (b >= 250); + return b % 10; +} + +/** + * Generates a random PIN of the given length. + * + * @param {number} length + * @returns {string} + */ +function generateRandomPin(length) { + return Array.from({ length }, () => randomDecimalDigit()).join(""); +} + +export default GeneratePIN; diff --git a/tests/operations/tests/Payment.mjs b/tests/operations/tests/Payment.mjs index c6bf0c49..27e0ae26 100644 --- a/tests/operations/tests/Payment.mjs +++ b/tests/operations/tests/Payment.mjs @@ -848,6 +848,76 @@ TestRegister.addTests([ } ] }, + { + name: "PIN Generate: 4-digit PIN digits", + input: "", + expectedMatch: /^\d{4}$/, + recipeConfig: [ + { + op: "PIN Generate", + args: [4, "PIN digits", ""] + } + ] + }, + { + name: "PIN Generate: 6-digit PIN digits", + input: "", + expectedMatch: /^\d{6}$/, + recipeConfig: [ + { + op: "PIN Generate", + args: [6, "PIN digits", ""] + } + ] + }, + { + name: "PIN Generate: ISO Format 0 block", + input: "", + expectedMatch: /^[0-9A-F]{16}$/, + recipeConfig: [ + { + op: "PIN Generate", + args: [4, "ISO Format 0 clear PIN block", "5432101234567890"] + } + ] + }, + { + name: "PIN Generate: ISO Format 1 block", + input: "", + expectedMatch: /^[0-9A-F]{16}$/, + recipeConfig: [ + { + op: "PIN Generate", + args: [4, "ISO Format 1 clear PIN block", ""] + } + ] + }, + { + name: "PIN Generate: ISO Format 3 block", + input: "", + expectedMatch: /^[0-9A-F]{16}$/, + recipeConfig: [ + { + op: "PIN Generate", + args: [4, "ISO Format 3 clear PIN block", "5432101234567890"] + } + ] + }, + { + name: "Chain: PIN Generate → PIN Data Generate (Format 0)", + input: "", + expectedMatch: /^[0-9A-F]{16}$/, + recipeConfig: [ + { + op: "PIN Generate", + args: [4, "PIN digits", ""] + }, + { + op: "PIN Data Generate", + args: ["ISO Format 0", "5432101234567890", false, false] + } + ] + }, { name: "PIN IBM 3624 Offset Generate: known sample", input: "1234",