UNPKG

emv

Version:

EMV / Chip and PIN CLI and library for PC/SC card readers

966 lines 35.4 kB
import { findTagInBuffer } from './emv-tags.js'; /** * Payment System Environment (PSE) identifier for contact cards * "1PAY.SYS.DDF01" encoded as bytes */ const PSE = Buffer.from([ 0x31, 0x50, 0x41, 0x59, 0x2e, 0x53, 0x59, 0x53, 0x2e, 0x44, 0x44, 0x46, 0x30, 0x31, ]); /** * Proximity Payment System Environment (PPSE) identifier for contactless cards * "2PAY.SYS.DDF01" encoded as bytes */ const PPSE = Buffer.from([ 0x32, 0x50, 0x41, 0x59, 0x2e, 0x53, 0x59, 0x53, 0x2e, 0x44, 0x44, 0x46, 0x30, 0x31, ]); /** * Parse APDU response into CardResponse */ function parseResponse(response) { const sw1 = response[response.length - 2] ?? 0; const sw2 = response[response.length - 1] ?? 0; const data = response.subarray(0, response.length - 2); return { buffer: data, sw1, sw2, isOk: () => sw1 === 0x90 && sw2 === 0x00, }; } /** * Build SELECT FILE APDU command */ function buildSelectApdu(data) { return Buffer.from([ 0x00, // CLA 0xa4, // INS: SELECT 0x04, // P1: Select by DF name 0x00, // P2: First or only occurrence data.length, // Lc ...data, 0x00, // Le: Maximum response length ]); } /** * Build READ RECORD APDU command */ function buildReadRecordApdu(sfi, record) { const p2 = (sfi << 3) | 0x04; // SFI in upper 5 bits, 0x04 = read record return Buffer.from([ 0x00, // CLA 0xb2, // INS: READ RECORD record, // P1: Record number p2, // P2: SFI and read mode 0x00, // Le: Maximum response length ]); } /** * Build GET DATA APDU command */ function buildGetDataApdu(tag) { const p1 = (tag >> 8) & 0xff; // High byte of tag const p2 = tag & 0xff; // Low byte of tag return Buffer.from([ 0x80, // CLA: proprietary 0xca, // INS: GET DATA p1, // P1: high byte of tag p2, // P2: low byte of tag 0x00, // Le: maximum response length ]); } /** * Build GET PROCESSING OPTIONS APDU command */ function buildGpoApdu(pdolData) { // Command data is wrapped in tag 83 (Command Template) const commandData = Buffer.from([0x83, pdolData.length, ...pdolData]); return Buffer.from([ 0x80, // CLA: proprietary 0xa8, // INS: GET PROCESSING OPTIONS 0x00, // P1 0x00, // P2 commandData.length, // Lc ...commandData, 0x00, // Le: maximum response length ]); } /** * Build GENERATE APPLICATION CRYPTOGRAM APDU command */ function buildGenerateAcApdu(cryptogramType, cdolData) { return Buffer.from([ 0x80, // CLA: proprietary 0xae, // INS: GENERATE AC cryptogramType, // P1: cryptogram type 0x00, // P2 cdolData.length, // Lc ...cdolData, 0x00, // Le: maximum response length ]); } /** * Build INTERNAL AUTHENTICATE APDU command */ function buildInternalAuthenticateApdu(authData) { return Buffer.from([ 0x00, // CLA 0x88, // INS: INTERNAL AUTHENTICATE 0x00, // P1 0x00, // P2 authData.length, // Lc ...authData, 0x00, // Le: maximum response length ]); } /** * Parse AFL (Application File Locator) from buffer. * Each AFL entry is 4 bytes: SFI (5 bits) | 000, first record, last record, SDA records */ export function parseAfl(buffer) { const entries = []; for (let i = 0; i + 3 < buffer.length; i += 4) { const sfiByte = buffer[i]; const firstRecord = buffer[i + 1]; const lastRecord = buffer[i + 2]; const sdaRecords = buffer[i + 3]; if (sfiByte === undefined || firstRecord === undefined || lastRecord === undefined || sdaRecords === undefined) { continue; } entries.push({ sfi: sfiByte >> 3, firstRecord, lastRecord, sdaRecords, }); } return entries; } /** * Parse a GPO (GET PROCESSING OPTIONS) response buffer. * Supports both Format 1 (tag 80) and Format 2 (tag 77) responses. * * @param buffer - Raw GPO response data (without status words) * @returns Parsed AIP and AFL entries */ export function parseGpoResponseBuffer(buffer) { if (buffer.length === 0) { return { aip: undefined, afl: [] }; } const firstByte = buffer[0]; if (firstByte === 0x80) { // Format 1: 80 len AIP(2) AFL(var) const len = buffer[1]; if (len === undefined || len < 2) { return { aip: undefined, afl: [] }; } // Validate buffer has enough data for header + len bytes (at least 4 for AIP) if (buffer.length < 4 || buffer.length < 2 + len) { return { aip: undefined, afl: [] }; } const aip = buffer.subarray(2, 4); const aflBuffer = buffer.subarray(4, 2 + len); return { aip, afl: parseAfl(aflBuffer) }; } else if (firstByte === 0x77) { // Format 2: TLV structure with tags 82 (AIP) and 94 (AFL) const aip = findTagInBuffer(buffer, 0x82); const aflBuffer = findTagInBuffer(buffer, 0x94); return { aip, afl: aflBuffer ? parseAfl(aflBuffer) : [] }; } // Unknown format return { aip: undefined, afl: [] }; } /** * Parse a Generate AC response buffer. * Extracts cryptogram type, cryptogram value, and ATC. * * @param buffer - Raw Generate AC response data (without status words) * @returns Parsed cryptogram details */ export function parseGenerateAcResponse(buffer) { const cid = findTagInBuffer(buffer, 0x9f27); const cryptogram = findTagInBuffer(buffer, 0x9f26); const atcBuffer = findTagInBuffer(buffer, 0x9f36); const cryptogramType = cid && cid[0] !== undefined ? byteToCryptogramType(cid[0]) : undefined; const atc = atcBuffer && atcBuffer.length >= 2 ? atcBuffer.readUInt16BE(0) : undefined; return { cryptogramType, cryptogram, atc }; } /** * Parse PDOL or CDOL (Data Object List) from buffer. * Format: tag (1-2 bytes) + length (1 byte), repeated */ export function parsePdol(buffer) { const entries = []; let i = 0; while (i < buffer.length) { const firstByte = buffer[i]; if (firstByte === undefined) break; let tag; // Check if it's a two-byte tag (first byte has bits 1-5 all set) if ((firstByte & 0x1f) === 0x1f) { const secondByte = buffer[i + 1]; if (secondByte === undefined) break; tag = (firstByte << 8) | secondByte; i += 2; } else { tag = firstByte; i += 1; } const length = buffer[i]; if (length === undefined) break; i += 1; entries.push({ tag, length }); } return entries; } /** * Build PDOL/CDOL data from tag entries and values. * Missing values are padded with zeros. */ export function buildPdolData(entries, tagValues) { const chunks = []; for (const entry of entries) { const value = tagValues.get(entry.tag); if (value) { if (value.length >= entry.length) { chunks.push(value.subarray(0, entry.length)); } else { // Pad with leading zeros if value is shorter const padded = Buffer.alloc(entry.length); value.copy(padded, entry.length - value.length); chunks.push(padded); } } else { // No value provided, use zeros chunks.push(Buffer.alloc(entry.length)); } } return Buffer.concat(chunks); } /** * Generate a random unpredictable number (4 bytes) */ function generateUnpredictableNumber() { return Buffer.from([ Math.floor(Math.random() * 256), Math.floor(Math.random() * 256), Math.floor(Math.random() * 256), Math.floor(Math.random() * 256), ]); } /** * Build PDOL data with sensible default values for common EMV tags. * Use this when you need to construct PDOL data for GPO but don't have * all the specific terminal values. * * @param entries - PDOL entries from parsePdol() * @param options - Transaction options (amount, currency, etc.) * @returns Buffer containing PDOL data ready for GPO */ export function buildDefaultPdolData(entries, options) { const { amount, currencyCode, transactionType = 0x00, overrides = new Map(), } = options; // Build default values for common PDOL tags const defaults = new Map([ [0x9f02, amountToBcd(amount)], // Amount, Authorized [0x9f03, Buffer.alloc(6)], // Amount, Other [0x9f1a, Buffer.from([(currencyCode >> 8) & 0xff, currencyCode & 0xff])], // Terminal Country Code [0x5f2a, Buffer.from([(currencyCode >> 8) & 0xff, currencyCode & 0xff])], // Transaction Currency Code [0x9a, getCurrentDateBcd()], // Transaction Date [0x9c, Buffer.from([transactionType])], // Transaction Type [0x9f37, generateUnpredictableNumber()], // Unpredictable Number [0x9f35, Buffer.from([0x22])], // Terminal Type [0x9f45, Buffer.alloc(2)], // Data Authentication Code [0x9f34, Buffer.from([0x00, 0x00, 0x00])], // CVM Results [0x9f66, Buffer.from([0x86, 0x00, 0x00, 0x00])], // TTQ (Terminal Transaction Qualifiers) ]); // Merge user overrides overrides.forEach((value, tag) => { defaults.set(tag, value); }); return buildPdolData(entries, defaults); } /** * Build standard CDOL data for Generate AC command. * This constructs the commonly required CDOL1 data in the standard order. * * @param options - Transaction options (amount, currency, etc.) * @returns Buffer containing CDOL data ready for Generate AC */ export function buildDefaultCdolData(options) { const { amount, currencyCode, transactionType = 0x00, tvr = Buffer.alloc(5), overrides = new Map(), } = options; // Standard CDOL1 fields in typical order const defaults = new Map([ [0x9f02, amountToBcd(amount)], // Amount, Authorized (6 bytes) [0x9f03, Buffer.alloc(6)], // Amount, Other (6 bytes) [0x9f1a, Buffer.from([(currencyCode >> 8) & 0xff, currencyCode & 0xff])], // Terminal Country Code (2 bytes) [0x95, tvr], // TVR (5 bytes) [0x5f2a, Buffer.from([(currencyCode >> 8) & 0xff, currencyCode & 0xff])], // Transaction Currency Code (2 bytes) [0x9a, getCurrentDateBcd()], // Transaction Date (3 bytes) [0x9c, Buffer.from([transactionType])], // Transaction Type (1 byte) [0x9f37, generateUnpredictableNumber()], // Unpredictable Number (4 bytes) ]); // Merge user overrides overrides.forEach((value, tag) => { defaults.set(tag, value); }); // Build in standard CDOL1 order return Buffer.concat([ defaults.get(0x9f02) ?? Buffer.alloc(6), // Amount defaults.get(0x9f03) ?? Buffer.alloc(6), // Other Amount defaults.get(0x9f1a) ?? Buffer.alloc(2), // Country Code defaults.get(0x95) ?? Buffer.alloc(5), // TVR defaults.get(0x5f2a) ?? Buffer.alloc(2), // Currency defaults.get(0x9a) ?? Buffer.alloc(3), // Date defaults.get(0x9c) ?? Buffer.alloc(1), // Type defaults.get(0x9f37) ?? Buffer.alloc(4), // Unpredictable Number ]); } /** * Parse CVM (Cardholder Verification Method) List from EMV tag 8E. * The CVM List contains amount thresholds and a list of verification rules. * * @param buffer - The CVM List data (tag 8E) * @returns Parsed CVM list with amount thresholds and rules */ export function parseCvmList(buffer) { // CVM List structure: // Bytes 0-3: Amount X (4 bytes, big-endian) // Bytes 4-7: Amount Y (4 bytes, big-endian) // Bytes 8+: CVM rules (2 bytes each) if (buffer.length < 8) { return { amountX: 0, amountY: 0, rules: [] }; } const amountX = buffer.readUInt32BE(0); const amountY = buffer.readUInt32BE(4); const rules = []; // Parse CVM rules (2 bytes each) for (let i = 8; i + 1 < buffer.length; i += 2) { const cvmByte = buffer[i]; const conditionByte = buffer[i + 1]; // Bit 6 of CVM byte indicates fail behavior (0 = fail, 1 = continue) const failIfUnsuccessful = (cvmByte & 0x40) === 0; // Bits 0-5 of CVM byte indicate the method const methodCode = cvmByte & 0x3f; const method = cvmCodeToMethod(methodCode); const condition = conditionByteToCondition(conditionByte); rules.push({ method, condition, failIfUnsuccessful, cvmByte, conditionByte, }); } return { amountX, amountY, rules }; } /** * Convert CVM method code to CvmMethod type */ function cvmCodeToMethod(code) { switch (code) { case 0x00: return 'fail'; case 0x01: return 'plaintext_pin_icc'; case 0x02: return 'enciphered_pin_online'; case 0x03: return 'plaintext_pin_icc_signature'; case 0x04: return 'enciphered_pin_icc'; case 0x05: return 'enciphered_pin_icc_signature'; case 0x1e: return 'signature'; case 0x1f: return 'no_cvm'; default: return 'unknown'; } } /** * Convert condition byte to CvmCondition type */ function conditionByteToCondition(code) { switch (code) { case 0x00: return 'always'; case 0x01: return 'unattended_cash'; case 0x02: return 'not_unattended_cash_manual_pin'; case 0x03: return 'terminal_supports_cvm'; case 0x04: return 'manual_cash'; case 0x05: return 'purchase_with_cashback'; case 0x06: return 'amount_under_x'; case 0x07: return 'amount_over_x'; case 0x08: return 'amount_under_y'; case 0x09: return 'amount_over_y'; default: return 'unknown'; } } /** * Evaluate CVM rules against a transaction context and return the first matching rule. * This simulates the terminal's CVM selection process. * * @param cvmList - Parsed CVM list * @param context - Transaction context with relevant conditions * @returns The first matching CVM rule, or undefined if none match */ export function evaluateCvm(cvmList, context) { for (const rule of cvmList.rules) { if (evaluateCondition(rule.condition, cvmList, context)) { return rule; } } return undefined; } /** * Evaluate a CVM condition against the transaction context */ function evaluateCondition(condition, cvmList, context) { switch (condition) { case 'always': return true; case 'unattended_cash': return context.unattendedCash === true; case 'not_unattended_cash_manual_pin': return (context.unattendedCash !== true && context.manualCash !== true && context.purchaseWithCashback !== true); case 'terminal_supports_cvm': return context.terminalSupportsCvm === true; case 'manual_cash': return context.manualCash === true; case 'purchase_with_cashback': return context.purchaseWithCashback === true; case 'amount_under_x': return context.amount !== undefined && context.amount < cvmList.amountX; case 'amount_over_x': return context.amount !== undefined && context.amount > cvmList.amountX; case 'amount_under_y': return context.amount !== undefined && context.amount < cvmList.amountY; case 'amount_over_y': return context.amount !== undefined && context.amount > cvmList.amountY; case 'unknown': default: return false; } } /** * Convert cryptogram type string to byte value */ function cryptogramTypeToByte(type) { switch (type) { case 'AAC': return 0x00; case 'TC': return 0x40; case 'ARQC': return 0x80; } } /** * Convert CID byte to cryptogram type string */ function byteToCryptogramType(cid) { const type = cid & 0xc0; switch (type) { case 0x00: return 'AAC'; case 0x40: return 'TC'; case 0x80: return 'ARQC'; default: return undefined; } } /** * Convert amount to 6-byte BCD format (12 digits) */ function amountToBcd(amount) { const str = amount.toString().padStart(12, '0'); const buf = Buffer.alloc(6); for (let i = 0; i < 6; i++) { const d1 = parseInt(str[i * 2] ?? '0', 10); const d2 = parseInt(str[i * 2 + 1] ?? '0', 10); buf[i] = (d1 << 4) | d2; } return buf; } /** * Convert a 2-character decimal string to a single BCD byte. * * Each character becomes one nibble: e.g. "25" -> 0x25 (high nibble 2, low nibble 5). * * @param str - A 2-character string containing decimal digits ("0"-"9") * @throws TypeError if input is not exactly 2 decimal digits */ export function stringToBcd(str) { if (!/^[0-9]{2}$/.test(str)) { throw new TypeError(`stringToBcd expected a 2-digit decimal string, got "${str}"`); } const high = parseInt(str.charAt(0), 10); const low = parseInt(str.charAt(1), 10); return (high << 4) | low; } /** * Get current date as BCD YYMMDD */ function getCurrentDateBcd() { const now = new Date(); const year = (now.getFullYear() % 100).toString().padStart(2, '0'); const month = (now.getMonth() + 1).toString().padStart(2, '0'); const day = now.getDate().toString().padStart(2, '0'); return Buffer.from([stringToBcd(year), stringToBcd(month), stringToBcd(day)]); } /** * Build PIN block in ISO 9564 Format 2 (BCD with 0xF padding) */ function buildPinBlock(pin) { const pinLength = pin.length; const pinBlock = Buffer.alloc(8); // First byte: 0x20 | PIN length pinBlock[0] = 0x20 | pinLength; // Encode PIN digits as BCD (two digits per byte), pad with 0xF for (let i = 0; i < 7; i++) { const char1 = pin[i * 2]; const char2 = pin[i * 2 + 1]; const digit1 = char1 !== undefined ? parseInt(char1, 10) : 0xf; const digit2 = char2 !== undefined ? parseInt(char2, 10) : 0xf; pinBlock[i + 1] = (digit1 << 4) | digit2; } return pinBlock; } /** * Build VERIFY PIN APDU command * PIN is encoded in ISO 9564 Format 2 (BCD with 0xF padding) */ function buildVerifyPinApdu(pin) { const pinBlock = buildPinBlock(pin); return Buffer.from([ 0x00, // CLA 0x20, // INS: VERIFY 0x00, // P1 0x80, // P2: plaintext PIN 0x08, // Lc: PIN block is always 8 bytes ...pinBlock, ]); } /** * Build CHANGE REFERENCE DATA APDU command for PIN change * Both old and new PINs are encoded in ISO 9564 Format 2 */ function buildChangePinApdu(oldPin, newPin) { const oldPinBlock = buildPinBlock(oldPin); const newPinBlock = buildPinBlock(newPin); return Buffer.from([ 0x00, // CLA 0x24, // INS: CHANGE REFERENCE DATA 0x00, // P1 0x80, // P2: plaintext PIN 0x10, // Lc: 16 bytes (2 x 8-byte PIN blocks) ...oldPinBlock, ...newPinBlock, ]); } /** * EMV Application for interacting with chip cards via PC/SC readers. * * @example * ```typescript * import { Devices } from 'smartcard'; * import { EmvApplication, format } from 'emv'; * * const devices = new Devices(); * * devices.on('card-inserted', async ({ reader, card }) => { * const emv = new EmvApplication(reader, card); * const response = await emv.selectPse(); * console.log(format(response)); * }); * * devices.start(); * ``` */ export class EmvApplication { #card; #reader; constructor(reader, card) { this.#reader = reader; this.#card = card; } /** * Transmit an APDU with automatic T=0 protocol handling */ async #transmit(apdu) { return this.#card.transmit(apdu, { autoGetResponse: true }); } /** * Select the Payment System Environment (PSE) directory. * This is typically the first command sent to a contact payment card. */ async selectPse() { const apdu = buildSelectApdu(PSE); const response = await this.#transmit(apdu); return parseResponse(response); } /** * Select the Proximity Payment System Environment (PPSE) directory. * This is the first command sent to a contactless payment card. */ async selectPpse() { const apdu = buildSelectApdu(PPSE); const response = await this.#transmit(apdu); return parseResponse(response); } /** * Select an EMV application by its AID. * @param aid - Application Identifier (5-16 bytes) */ async selectApplication(aid) { const aidBuffer = Buffer.isBuffer(aid) ? aid : Buffer.from(aid); if (aidBuffer.length < 5 || aidBuffer.length > 16) { throw new RangeError('AID must be between 5 and 16 bytes'); } const apdu = buildSelectApdu(aidBuffer); const response = await this.#transmit(apdu); return parseResponse(response); } /** * Read a record from a Short File Identifier (SFI). * @param sfi - Short File Identifier (1-30) * @param record - Record number (0-255) */ async readRecord(sfi, record) { if (!Number.isInteger(sfi) || sfi < 1 || sfi > 30) { throw new RangeError('SFI must be an integer between 1 and 30'); } if (!Number.isInteger(record) || record < 0 || record > 255) { throw new RangeError('Record number must be an integer between 0 and 255'); } const apdu = buildReadRecordApdu(sfi, record); const response = await this.#transmit(apdu); return parseResponse(response); } /** * Read all records specified in the Application File Locator (AFL). * This automates the process of reading all card data after GPO. * * @param afl - AFL entries (from parseAfl) or raw AFL buffer * @returns Array of records with SFI and record number metadata */ async readAllRecords(afl) { const entries = Buffer.isBuffer(afl) ? parseAfl(afl) : afl; const records = []; for (const entry of entries) { for (let recordNum = entry.firstRecord; recordNum <= entry.lastRecord; recordNum++) { const response = await this.readRecord(entry.sfi, recordNum); if (response.isOk()) { records.push({ sfi: entry.sfi, recordNumber: recordNum, data: response.buffer, }); } // Skip failed records silently - they may not exist on all cards } } return records; } /** * Discover payment applications on the card via PSE (Payment System Environment). * This reads the PSE directory and extracts information about available applications. * * @returns Result containing list of discovered applications */ async discoverApplications() { const pseResponse = await this.selectPse(); if (!pseResponse.isOk()) { return { success: false, apps: [], sfi: 1 }; } const sfiData = findTagInBuffer(pseResponse.buffer, 0x88); const sfi = sfiData?.[0] ?? 1; const apps = []; for (let record = 1; record <= 10; record++) { const response = await this.readRecord(sfi, record); if (!response.isOk()) break; const aid = findTagInBuffer(response.buffer, 0x4f); if (aid) { const label = findTagInBuffer(response.buffer, 0x50); const priority = findTagInBuffer(response.buffer, 0x87); apps.push({ aid: aid.toString('hex'), label: label?.toString('ascii'), priority: priority?.[0], }); } } return { success: true, apps, sfi, pseBuffer: pseResponse.buffer }; } /** * Verify the cardholder PIN (plaintext). * @param pin - PIN code as a string of 4-12 digits * @returns CardResponse with status words indicating success or failure: * - SW 9000: PIN verified successfully * - SW 63CX: Wrong PIN, X attempts remaining * - SW 6983: PIN blocked (too many failed attempts) * - SW 6984: PIN not initialized */ async verifyPin(pin) { if (!/^\d{4,12}$/.test(pin)) { throw new RangeError('PIN must be a string of 4-12 digits'); } const apdu = buildVerifyPinApdu(pin); const response = await this.#transmit(apdu); return parseResponse(response); } /** * Change the cardholder PIN (plaintext). * * **Note:** Most payment cards restrict PIN change to specific environments * (ATM, bank terminal). This method is primarily useful for test cards. * * @param oldPin - Current PIN code as a string of 4-12 digits * @param newPin - New PIN code as a string of 4-12 digits * @returns CardResponse with status words indicating success or failure: * - SW 9000: PIN changed successfully * - SW 63CX: Wrong old PIN, X attempts remaining * - SW 6983: PIN blocked (too many failed attempts) * - SW 6984: PIN not initialized */ async changePin(oldPin, newPin) { if (!/^\d{4,12}$/.test(oldPin)) { throw new RangeError('Old PIN must be a string of 4-12 digits'); } if (!/^\d{4,12}$/.test(newPin)) { throw new RangeError('New PIN must be a string of 4-12 digits'); } const apdu = buildChangePinApdu(oldPin, newPin); const response = await this.#transmit(apdu); return parseResponse(response); } /** * Get data element from the card by tag. * @param tag - EMV tag (1-2 bytes, e.g., 0x9F17 for PIN Try Counter) * @returns CardResponse with the requested data or error status: * - SW 9000: Success, data returned * - SW 6A88: Referenced data not found */ async getData(tag) { if (!Number.isInteger(tag) || tag < 0 || tag > 0xffff) { throw new RangeError('Tag must be a positive integer (0x0000-0xFFFF)'); } const apdu = buildGetDataApdu(tag); const response = await this.#transmit(apdu); return parseResponse(response); } /** * Initiate transaction processing (GET PROCESSING OPTIONS). * @param pdolData - Optional PDOL (Processing Data Object List) values * @returns CardResponse containing AIP and AFL on success: * - SW 9000: Success, AIP and AFL returned * - SW 6985: Conditions of use not satisfied */ async getProcessingOptions(pdolData) { const data = pdolData ? Buffer.isBuffer(pdolData) ? pdolData : Buffer.from(pdolData) : Buffer.alloc(0); const apdu = buildGpoApdu(data); const response = await this.#transmit(apdu); return parseResponse(response); } /** * Generate an Application Cryptogram for transaction authorization. * @param cryptogramType - Type of cryptogram to generate: * - 0x00: AAC (Application Authentication Cryptogram) - decline * - 0x40: TC (Transaction Certificate) - approve offline * - 0x80: ARQC (Authorization Request Cryptogram) - go online * @param cdolData - CDOL (Card Data Object List) data * @returns CardResponse containing the cryptogram on success */ async generateAc(cryptogramType, cdolData) { if (cryptogramType !== 0x00 && cryptogramType !== 0x40 && cryptogramType !== 0x80) { throw new RangeError('Cryptogram type must be AAC (0x00), TC (0x40), or ARQC (0x80)'); } const data = Buffer.isBuffer(cdolData) ? cdolData : Buffer.from(cdolData); if (data.length === 0) { throw new RangeError('CDOL data must not be empty'); } const apdu = buildGenerateAcApdu(cryptogramType, data); const response = await this.#transmit(apdu); return parseResponse(response); } /** * Perform internal authentication for Dynamic Data Authentication (DDA). * @param authData - Authentication data (typically unpredictable number from terminal) * @returns CardResponse containing signed dynamic application data */ async internalAuthenticate(authData) { const data = Buffer.isBuffer(authData) ? authData : Buffer.from(authData); if (data.length === 0) { throw new RangeError('Authentication data must not be empty'); } const apdu = buildInternalAuthenticateApdu(data); const response = await this.#transmit(apdu); return parseResponse(response); } /** * Get the card's ATR (Answer To Reset) */ getAtr() { return this.#card.atr.toString('hex'); } /** * Get the reader name */ getReaderName() { return this.#reader.name; } /** * Perform a complete EMV transaction flow. * This orchestrates: GPO → Read Records → Generate AC * * @param options - Transaction options including amount and currency * @returns TransactionResult with cryptogram and card data */ async performTransaction(options) { const { amount, currencyCode, transactionType = 0x00, cryptogramType = 'ARQC', pdolValues = new Map(), cdolValues = new Map(), } = options; // Build default tag values for PDOL const defaultPdolValues = new Map([ [0x9f02, amountToBcd(amount)], // Amount, Authorized [0x9f03, Buffer.alloc(6)], // Amount, Other [0x9f1a, Buffer.from([(currencyCode >> 8) & 0xff, currencyCode & 0xff])], // Terminal Country Code [0x5f2a, Buffer.from([(currencyCode >> 8) & 0xff, currencyCode & 0xff])], // Transaction Currency Code [0x9a, getCurrentDateBcd()], // Transaction Date [0x9c, Buffer.from([transactionType])], // Transaction Type [ 0x9f37, Buffer.from([ Math.random() * 256, Math.random() * 256, Math.random() * 256, Math.random() * 256, ].map(Math.floor)), ], // Unpredictable Number [0x9f35, Buffer.from([0x22])], // Terminal Type [0x9f45, Buffer.alloc(2)], // Data Authentication Code [0x9f34, Buffer.from([0x00, 0x00, 0x00])], // CVM Results [0x9f66, Buffer.from([0x86, 0x00, 0x00, 0x00])], // TTQ (Terminal Transaction Qualifiers) ]); // Merge with user-provided values pdolValues.forEach((value, tag) => { defaultPdolValues.set(tag, value); }); // Step 1: GET PROCESSING OPTIONS // For simplicity, we'll use empty PDOL data if card doesn't require specific data const gpoResponse = await this.getProcessingOptions(); if (!gpoResponse.isOk()) { return { success: false, error: `GPO failed with SW ${gpoResponse.sw1.toString(16).padStart(2, '0')}${gpoResponse.sw2.toString(16).padStart(2, '0')}`, }; } // Parse GPO response using the pure function const { aip, afl } = parseGpoResponseBuffer(gpoResponse.buffer); // Step 2: Read records from AFL const records = []; for (const entry of afl) { for (let rec = entry.firstRecord; rec <= entry.lastRecord; rec++) { const recordResponse = await this.readRecord(entry.sfi, rec); if (recordResponse.isOk()) { records.push(recordResponse.buffer); } } } // Step 3: Build CDOL data and Generate AC // Use a minimal CDOL if we don't have the actual CDOL from the card const defaultCdolValues = new Map([ [0x9f02, amountToBcd(amount)], // Amount, Authorized [0x9f03, Buffer.alloc(6)], // Amount, Other [0x9f1a, Buffer.from([(currencyCode >> 8) & 0xff, currencyCode & 0xff])], // Terminal Country Code [0x95, Buffer.alloc(5)], // TVR [0x5f2a, Buffer.from([(currencyCode >> 8) & 0xff, currencyCode & 0xff])], // Transaction Currency Code [0x9a, getCurrentDateBcd()], // Transaction Date [0x9c, Buffer.from([transactionType])], // Transaction Type [ 0x9f37, Buffer.from([ Math.random() * 256, Math.random() * 256, Math.random() * 256, Math.random() * 256, ].map(Math.floor)), ], // Unpredictable Number [0x82, aip ?? Buffer.alloc(2)], // AIP [0x9f36, Buffer.alloc(2)], // ATC (placeholder) ]); // Merge with user-provided values cdolValues.forEach((value, tag) => { defaultCdolValues.set(tag, value); }); // Build a minimal CDOL data buffer (common fields) const cdolData = Buffer.concat([ defaultCdolValues.get(0x9f02) ?? Buffer.alloc(6), // Amount defaultCdolValues.get(0x9f03) ?? Buffer.alloc(6), // Other Amount defaultCdolValues.get(0x9f1a) ?? Buffer.alloc(2), // Country Code defaultCdolValues.get(0x95) ?? Buffer.alloc(5), // TVR defaultCdolValues.get(0x5f2a) ?? Buffer.alloc(2), // Currency defaultCdolValues.get(0x9a) ?? Buffer.alloc(3), // Date defaultCdolValues.get(0x9c) ?? Buffer.alloc(1), // Type defaultCdolValues.get(0x9f37) ?? Buffer.alloc(4), // Unpredictable Number ]); const cryptogramByte = cryptogramTypeToByte(cryptogramType); const acResponse = await this.generateAc(cryptogramByte, cdolData); if (!acResponse.isOk()) { return { success: false, error: `Generate AC failed with SW ${acResponse.sw1.toString(16).padStart(2, '0')}${acResponse.sw2.toString(16).padStart(2, '0')}`, aip, afl, records, }; } // Parse Generate AC response using the pure function const acResult = parseGenerateAcResponse(acResponse.buffer); return { success: true, aip, afl, records, cryptogramType: acResult.cryptogramType, cryptogram: acResult.cryptogram, atc: acResult.atc, generateAcResponse: acResponse.buffer, }; } } /** * Factory function to create an EmvApplication instance */ export function createEmvApplication(reader, card) { return new EmvApplication(reader, card); } export default createEmvApplication; //# sourceMappingURL=emv-application.js.map