emv
Version:
EMV / Chip and PIN CLI and library for PC/SC card readers
1,044 lines • 53.3 kB
JavaScript
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert';
import { EmvApplication, createEmvApplication } from './index.js';
describe('EmvApplication', () => {
let emv;
let mockReader;
let mockCard;
let transmitCalls;
let transmitCallsWithOptions;
beforeEach(() => {
transmitCalls = [];
transmitCallsWithOptions = [];
mockReader = { name: 'Test Reader' };
mockCard = {
atr: Buffer.from([0x3b, 0x8f, 0x80, 0x01]),
transmit: async (apdu, options) => {
const buf = Buffer.isBuffer(apdu) ? apdu : Buffer.from(apdu);
transmitCalls.push(buf);
transmitCallsWithOptions.push({ apdu: buf, options });
return Buffer.from([0x6f, 0x00, 0x90, 0x00]);
},
};
emv = new EmvApplication(mockReader, mockCard);
});
describe('constructor', () => {
it('should create an instance', () => {
assert.ok(emv instanceof EmvApplication);
});
});
describe('createEmvApplication', () => {
it('should create an EmvApplication instance', () => {
const instance = createEmvApplication(mockReader, mockCard);
assert.ok(instance instanceof EmvApplication);
});
});
describe('getAtr', () => {
it('should return ATR as hex string', () => {
assert.strictEqual(emv.getAtr(), '3b8f8001');
});
});
describe('getReaderName', () => {
it('should return reader name', () => {
assert.strictEqual(emv.getReaderName(), 'Test Reader');
});
});
describe('selectPse', () => {
it('should transmit SELECT APDU for PSE', async () => {
const response = await emv.selectPse();
assert.ok(response);
assert.strictEqual(response.isOk(), true);
assert.strictEqual(transmitCalls.length, 1);
const apdu = transmitCalls[0];
assert.ok(apdu);
assert.strictEqual(apdu[0], 0x00); // CLA
assert.strictEqual(apdu[1], 0xa4); // INS: SELECT
assert.strictEqual(apdu[2], 0x04); // P1
assert.strictEqual(apdu[3], 0x00); // P2
});
it('should parse response correctly', async () => {
mockCard.transmit = async () => Buffer.from([0x6f, 0x0a, 0x84, 0x07, 0x90, 0x00]);
const response = await emv.selectPse();
assert.strictEqual(response.sw1, 0x90);
assert.strictEqual(response.sw2, 0x00);
assert.strictEqual(response.buffer.toString('hex'), '6f0a8407');
});
});
describe('selectPpse', () => {
it('should transmit SELECT APDU for PPSE (contactless)', async () => {
const response = await emv.selectPpse();
assert.ok(response);
assert.strictEqual(response.isOk(), true);
assert.strictEqual(transmitCalls.length, 1);
const apdu = transmitCalls[0];
assert.ok(apdu);
assert.strictEqual(apdu[0], 0x00); // CLA
assert.strictEqual(apdu[1], 0xa4); // INS: SELECT
assert.strictEqual(apdu[2], 0x04); // P1
assert.strictEqual(apdu[3], 0x00); // P2
// PPSE = "2PAY.SYS.DDF01" = 0x32 0x50 0x41 0x59 0x2e 0x53 0x59 0x53 0x2e 0x44 0x44 0x46 0x30 0x31
assert.strictEqual(apdu[4], 14); // Length of PPSE
assert.strictEqual(apdu[5], 0x32); // '2'
});
it('should parse PPSE response correctly', async () => {
mockCard.transmit = async () => Buffer.from([0x6f, 0x0a, 0x84, 0x07, 0x90, 0x00]);
const response = await emv.selectPpse();
assert.strictEqual(response.sw1, 0x90);
assert.strictEqual(response.sw2, 0x00);
assert.strictEqual(response.buffer.toString('hex'), '6f0a8407');
});
});
describe('selectApplication', () => {
it('should throw RangeError for AID shorter than 5 bytes', async () => {
await assert.rejects(() => emv.selectApplication([0xa0, 0x00, 0x00, 0x00]), /AID must be between 5 and 16 bytes/);
});
it('should throw RangeError for AID longer than 16 bytes', async () => {
const longAid = new Array(17).fill(0xa0);
await assert.rejects(() => emv.selectApplication(longAid), /AID must be between 5 and 16 bytes/);
});
it('should accept valid AID as array', async () => {
const aid = [0xa0, 0x00, 0x00, 0x00, 0x04, 0x10, 0x10];
const response = await emv.selectApplication(aid);
assert.ok(response);
});
it('should accept valid AID as Buffer', async () => {
const aid = Buffer.from([0xa0, 0x00, 0x00, 0x00, 0x04, 0x10, 0x10]);
const response = await emv.selectApplication(aid);
assert.ok(response);
});
it('should include AID in APDU', async () => {
const aid = [0xa0, 0x00, 0x00, 0x00, 0x04];
await emv.selectApplication(aid);
const apdu = transmitCalls[0];
assert.ok(apdu);
assert.strictEqual(apdu[4], 5); // Lc = length of AID
assert.strictEqual(apdu.subarray(5, 10).toString('hex'), 'a000000004');
});
});
describe('readRecord', () => {
it('should throw RangeError for SFI less than 1', async () => {
await assert.rejects(() => emv.readRecord(0, 1), /SFI must be an integer between 1 and 30/);
});
it('should throw RangeError for SFI greater than 30', async () => {
await assert.rejects(() => emv.readRecord(31, 1), /SFI must be an integer between 1 and 30/);
});
it('should throw RangeError for negative record number', async () => {
await assert.rejects(() => emv.readRecord(1, -1), /Record number must be an integer between 0 and 255/);
});
it('should throw RangeError for record number greater than 255', async () => {
await assert.rejects(() => emv.readRecord(1, 256), /Record number must be an integer between 0 and 255/);
});
it('should accept valid SFI and record values', async () => {
const response = await emv.readRecord(1, 1);
assert.ok(response);
});
it('should encode SFI correctly in P2', async () => {
mockCard.transmit = async (apdu) => {
transmitCalls.push(Buffer.isBuffer(apdu) ? apdu : Buffer.from(apdu));
return Buffer.from([0x6a, 0x83]);
};
await emv.readRecord(1, 1);
const apdu = transmitCalls[0];
assert.ok(apdu);
assert.strictEqual(apdu[0], 0x00); // CLA
assert.strictEqual(apdu[1], 0xb2); // INS: READ RECORD
assert.strictEqual(apdu[2], 1); // P1: record number
assert.strictEqual(apdu[3], (1 << 3) | 0x04); // P2: SFI=1
});
it('should return non-OK response for error status words', async () => {
mockCard.transmit = async () => Buffer.from([0x6a, 0x83]);
const response = await emv.readRecord(1, 1);
assert.strictEqual(response.isOk(), false);
assert.strictEqual(response.sw1, 0x6a);
assert.strictEqual(response.sw2, 0x83);
});
});
describe('verifyPin', () => {
it('should throw RangeError for PIN shorter than 4 digits', async () => {
await assert.rejects(() => emv.verifyPin('123'), /PIN must be a string of 4-12 digits/);
});
it('should throw RangeError for PIN longer than 12 digits', async () => {
await assert.rejects(() => emv.verifyPin('1234567890123'), /PIN must be a string of 4-12 digits/);
});
it('should throw RangeError for non-numeric PIN', async () => {
await assert.rejects(() => emv.verifyPin('12ab'), /PIN must be a string of 4-12 digits/);
});
it('should transmit VERIFY APDU with correct format', async () => {
await emv.verifyPin('1234');
assert.strictEqual(transmitCalls.length, 1);
const apdu = transmitCalls[0];
assert.ok(apdu);
assert.strictEqual(apdu[0], 0x00); // CLA
assert.strictEqual(apdu[1], 0x20); // INS: VERIFY
assert.strictEqual(apdu[2], 0x00); // P1
assert.strictEqual(apdu[3], 0x80); // P2: plaintext PIN
assert.strictEqual(apdu[4], 0x08); // Lc: 8 bytes
});
it('should encode 4-digit PIN correctly in BCD format', async () => {
await emv.verifyPin('1234');
const apdu = transmitCalls[0];
assert.ok(apdu);
// PIN block: 0x24 (length=4) + 0x12 0x34 0xFF 0xFF 0xFF 0xFF 0xFF
assert.strictEqual(apdu[5], 0x24); // 0x20 | 4
assert.strictEqual(apdu[6], 0x12);
assert.strictEqual(apdu[7], 0x34);
assert.strictEqual(apdu[8], 0xff);
assert.strictEqual(apdu[9], 0xff);
assert.strictEqual(apdu[10], 0xff);
assert.strictEqual(apdu[11], 0xff);
assert.strictEqual(apdu[12], 0xff);
});
it('should encode 6-digit PIN correctly', async () => {
await emv.verifyPin('123456');
const apdu = transmitCalls[0];
assert.ok(apdu);
// PIN block: 0x26 (length=6) + 0x12 0x34 0x56 0xFF 0xFF 0xFF 0xFF
assert.strictEqual(apdu[5], 0x26);
assert.strictEqual(apdu[6], 0x12);
assert.strictEqual(apdu[7], 0x34);
assert.strictEqual(apdu[8], 0x56);
assert.strictEqual(apdu[9], 0xff);
});
it('should return success for correct PIN', async () => {
mockCard.transmit = async () => Buffer.from([0x90, 0x00]);
const response = await emv.verifyPin('1234');
assert.strictEqual(response.isOk(), true);
assert.strictEqual(response.sw1, 0x90);
assert.strictEqual(response.sw2, 0x00);
});
it('should return wrong PIN status with remaining attempts', async () => {
// 63C2 = wrong PIN, 2 attempts remaining
mockCard.transmit = async () => Buffer.from([0x63, 0xc2]);
const response = await emv.verifyPin('0000');
assert.strictEqual(response.isOk(), false);
assert.strictEqual(response.sw1, 0x63);
assert.strictEqual(response.sw2, 0xc2);
});
it('should return PIN blocked status', async () => {
// 6983 = PIN blocked
mockCard.transmit = async () => Buffer.from([0x69, 0x83]);
const response = await emv.verifyPin('0000');
assert.strictEqual(response.isOk(), false);
assert.strictEqual(response.sw1, 0x69);
assert.strictEqual(response.sw2, 0x83);
});
});
describe('changePin', () => {
it('should throw RangeError for old PIN shorter than 4 digits', async () => {
await assert.rejects(() => emv.changePin('123', '1234'), /Old PIN must be a string of 4-12 digits/);
});
it('should throw RangeError for new PIN shorter than 4 digits', async () => {
await assert.rejects(() => emv.changePin('1234', '12'), /New PIN must be a string of 4-12 digits/);
});
it('should throw RangeError for non-numeric old PIN', async () => {
await assert.rejects(() => emv.changePin('12ab', '1234'), /Old PIN must be a string of 4-12 digits/);
});
it('should throw RangeError for non-numeric new PIN', async () => {
await assert.rejects(() => emv.changePin('1234', 'abcd'), /New PIN must be a string of 4-12 digits/);
});
it('should transmit CHANGE REFERENCE DATA APDU with correct format', async () => {
await emv.changePin('1234', '5678');
assert.strictEqual(transmitCalls.length, 1);
const apdu = transmitCalls[0];
assert.ok(apdu);
assert.strictEqual(apdu[0], 0x00); // CLA
assert.strictEqual(apdu[1], 0x24); // INS: CHANGE REFERENCE DATA
assert.strictEqual(apdu[2], 0x00); // P1
assert.strictEqual(apdu[3], 0x80); // P2: plaintext PIN
assert.strictEqual(apdu[4], 0x10); // Lc: 16 bytes (2 x 8-byte PIN blocks)
});
it('should encode old and new PINs correctly in BCD format', async () => {
await emv.changePin('1234', '5678');
const apdu = transmitCalls[0];
assert.ok(apdu);
// Old PIN block: 0x24 (length=4) + 0x12 0x34 0xFF 0xFF 0xFF 0xFF 0xFF
assert.strictEqual(apdu[5], 0x24); // 0x20 | 4
assert.strictEqual(apdu[6], 0x12);
assert.strictEqual(apdu[7], 0x34);
assert.strictEqual(apdu[8], 0xff);
// New PIN block: 0x24 (length=4) + 0x56 0x78 0xFF 0xFF 0xFF 0xFF 0xFF
assert.strictEqual(apdu[13], 0x24); // 0x20 | 4
assert.strictEqual(apdu[14], 0x56);
assert.strictEqual(apdu[15], 0x78);
assert.strictEqual(apdu[16], 0xff);
});
it('should return success for PIN change', async () => {
mockCard.transmit = async () => Buffer.from([0x90, 0x00]);
const response = await emv.changePin('1234', '5678');
assert.strictEqual(response.isOk(), true);
});
it('should return wrong PIN status with remaining attempts', async () => {
// 63C2 = wrong PIN, 2 attempts remaining
mockCard.transmit = async () => Buffer.from([0x63, 0xc2]);
const response = await emv.changePin('0000', '1234');
assert.strictEqual(response.isOk(), false);
assert.strictEqual(response.sw1, 0x63);
assert.strictEqual(response.sw2, 0xc2);
});
it('should return PIN blocked status', async () => {
// 6983 = PIN blocked
mockCard.transmit = async () => Buffer.from([0x69, 0x83]);
const response = await emv.changePin('0000', '1234');
assert.strictEqual(response.isOk(), false);
assert.strictEqual(response.sw1, 0x69);
assert.strictEqual(response.sw2, 0x83);
});
});
describe('getData', () => {
it('should throw RangeError for tag less than 0', async () => {
await assert.rejects(() => emv.getData(-1), /Tag must be a positive integer/);
});
it('should throw RangeError for tag greater than 0xFFFF', async () => {
await assert.rejects(() => emv.getData(0x10000), /Tag must be a positive integer/);
});
it('should transmit GET DATA APDU with 1-byte tag', async () => {
await emv.getData(0x9f);
assert.strictEqual(transmitCalls.length, 1);
const apdu = transmitCalls[0];
assert.ok(apdu);
assert.strictEqual(apdu[0], 0x80); // CLA
assert.strictEqual(apdu[1], 0xca); // INS: GET DATA
assert.strictEqual(apdu[2], 0x00); // P1: high byte of tag
assert.strictEqual(apdu[3], 0x9f); // P2: low byte of tag
assert.strictEqual(apdu[4], 0x00); // Le
});
it('should transmit GET DATA APDU with 2-byte tag', async () => {
// 0x9F17 = PIN Try Counter
await emv.getData(0x9f17);
assert.strictEqual(transmitCalls.length, 1);
const apdu = transmitCalls[0];
assert.ok(apdu);
assert.strictEqual(apdu[0], 0x80); // CLA
assert.strictEqual(apdu[1], 0xca); // INS: GET DATA
assert.strictEqual(apdu[2], 0x9f); // P1: high byte of tag
assert.strictEqual(apdu[3], 0x17); // P2: low byte of tag
assert.strictEqual(apdu[4], 0x00); // Le
});
it('should return PIN try counter data', async () => {
// Response: 9F17 01 03 (PIN try counter = 3)
mockCard.transmit = async () => Buffer.from([0x9f, 0x17, 0x01, 0x03, 0x90, 0x00]);
const response = await emv.getData(0x9f17);
assert.strictEqual(response.isOk(), true);
assert.strictEqual(response.buffer.toString('hex'), '9f170103');
});
it('should return data not found status', async () => {
// 6A88 = Referenced data not found
mockCard.transmit = async () => Buffer.from([0x6a, 0x88]);
const response = await emv.getData(0x9f99);
assert.strictEqual(response.isOk(), false);
assert.strictEqual(response.sw1, 0x6a);
assert.strictEqual(response.sw2, 0x88);
});
});
describe('getProcessingOptions', () => {
it('should transmit GPO APDU with empty PDOL', async () => {
await emv.getProcessingOptions();
assert.strictEqual(transmitCalls.length, 1);
const apdu = transmitCalls[0];
assert.ok(apdu);
assert.strictEqual(apdu[0], 0x80); // CLA
assert.strictEqual(apdu[1], 0xa8); // INS: GET PROCESSING OPTIONS
assert.strictEqual(apdu[2], 0x00); // P1
assert.strictEqual(apdu[3], 0x00); // P2
assert.strictEqual(apdu[4], 0x02); // Lc: 2 bytes (tag 83 + length 0)
assert.strictEqual(apdu[5], 0x83); // Tag 83
assert.strictEqual(apdu[6], 0x00); // Length 0
assert.strictEqual(apdu[7], 0x00); // Le
});
it('should transmit GPO APDU with PDOL data', async () => {
const pdolData = Buffer.from([0x01, 0x02, 0x03, 0x04]);
await emv.getProcessingOptions(pdolData);
assert.strictEqual(transmitCalls.length, 1);
const apdu = transmitCalls[0];
assert.ok(apdu);
assert.strictEqual(apdu[0], 0x80); // CLA
assert.strictEqual(apdu[1], 0xa8); // INS: GET PROCESSING OPTIONS
assert.strictEqual(apdu[4], 0x06); // Lc: 6 bytes (tag 83 + length 4 + data)
assert.strictEqual(apdu[5], 0x83); // Tag 83
assert.strictEqual(apdu[6], 0x04); // Length 4
assert.strictEqual(apdu[7], 0x01); // Data
assert.strictEqual(apdu[8], 0x02);
assert.strictEqual(apdu[9], 0x03);
assert.strictEqual(apdu[10], 0x04);
});
it('should accept PDOL data as array', async () => {
await emv.getProcessingOptions([0xaa, 0xbb]);
const apdu = transmitCalls[0];
assert.ok(apdu);
assert.strictEqual(apdu[6], 0x02); // Length 2
assert.strictEqual(apdu[7], 0xaa);
assert.strictEqual(apdu[8], 0xbb);
});
it('should return AIP and AFL on success', async () => {
// Format 1 response: 80 06 1C00 08010100
mockCard.transmit = async () => Buffer.from([0x80, 0x06, 0x1c, 0x00, 0x08, 0x01, 0x01, 0x00, 0x90, 0x00]);
const response = await emv.getProcessingOptions();
assert.strictEqual(response.isOk(), true);
assert.strictEqual(response.buffer.toString('hex'), '80061c0008010100');
});
it('should return conditions not satisfied error', async () => {
// 6985 = Conditions of use not satisfied
mockCard.transmit = async () => Buffer.from([0x69, 0x85]);
const response = await emv.getProcessingOptions();
assert.strictEqual(response.isOk(), false);
assert.strictEqual(response.sw1, 0x69);
assert.strictEqual(response.sw2, 0x85);
});
});
describe('generateAc', () => {
it('should throw RangeError for invalid cryptogram type', async () => {
await assert.rejects(() => emv.generateAc(0x20, Buffer.from([0x01])), /Cryptogram type must be AAC \(0x00\), TC \(0x40\), or ARQC \(0x80\)/);
});
it('should throw RangeError for empty CDOL data', async () => {
await assert.rejects(() => emv.generateAc(0x80, Buffer.alloc(0)), /CDOL data must not be empty/);
});
it('should transmit GENERATE AC APDU for ARQC', async () => {
const cdolData = Buffer.from([0x01, 0x02, 0x03, 0x04]);
await emv.generateAc(0x80, cdolData);
assert.strictEqual(transmitCalls.length, 1);
const apdu = transmitCalls[0];
assert.ok(apdu);
assert.strictEqual(apdu[0], 0x80); // CLA
assert.strictEqual(apdu[1], 0xae); // INS: GENERATE AC
assert.strictEqual(apdu[2], 0x80); // P1: ARQC
assert.strictEqual(apdu[3], 0x00); // P2
assert.strictEqual(apdu[4], 0x04); // Lc
assert.strictEqual(apdu[5], 0x01); // Data
assert.strictEqual(apdu[6], 0x02);
assert.strictEqual(apdu[7], 0x03);
assert.strictEqual(apdu[8], 0x04);
assert.strictEqual(apdu[9], 0x00); // Le
});
it('should transmit GENERATE AC APDU for TC', async () => {
await emv.generateAc(0x40, [0xaa, 0xbb]);
const apdu = transmitCalls[0];
assert.ok(apdu);
assert.strictEqual(apdu[2], 0x40); // P1: TC
});
it('should transmit GENERATE AC APDU for AAC', async () => {
await emv.generateAc(0x00, [0xcc]);
const apdu = transmitCalls[0];
assert.ok(apdu);
assert.strictEqual(apdu[2], 0x00); // P1: AAC
});
it('should return cryptogram on success', async () => {
// Response with Application Cryptogram
mockCard.transmit = async () => Buffer.from([
0x77, 0x12, 0x9f, 0x27, 0x01, 0x80, 0x9f, 0x36, 0x02, 0x00, 0x01, 0x9f, 0x26,
0x08, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0x90, 0x00,
]);
const response = await emv.generateAc(0x80, [0x01]);
assert.strictEqual(response.isOk(), true);
});
it('should return conditions not satisfied error', async () => {
mockCard.transmit = async () => Buffer.from([0x69, 0x85]);
const response = await emv.generateAc(0x80, [0x01]);
assert.strictEqual(response.isOk(), false);
assert.strictEqual(response.sw1, 0x69);
assert.strictEqual(response.sw2, 0x85);
});
});
describe('internalAuthenticate', () => {
it('should throw RangeError for empty authentication data', async () => {
await assert.rejects(() => emv.internalAuthenticate(Buffer.alloc(0)), /Authentication data must not be empty/);
});
it('should transmit INTERNAL AUTHENTICATE APDU', async () => {
const authData = Buffer.from([0x12, 0x34, 0x56, 0x78]);
await emv.internalAuthenticate(authData);
assert.strictEqual(transmitCalls.length, 1);
const apdu = transmitCalls[0];
assert.ok(apdu);
assert.strictEqual(apdu[0], 0x00); // CLA
assert.strictEqual(apdu[1], 0x88); // INS: INTERNAL AUTHENTICATE
assert.strictEqual(apdu[2], 0x00); // P1
assert.strictEqual(apdu[3], 0x00); // P2
assert.strictEqual(apdu[4], 0x04); // Lc
assert.strictEqual(apdu[5], 0x12); // Data
assert.strictEqual(apdu[6], 0x34);
assert.strictEqual(apdu[7], 0x56);
assert.strictEqual(apdu[8], 0x78);
assert.strictEqual(apdu[9], 0x00); // Le
});
it('should accept authentication data as array', async () => {
await emv.internalAuthenticate([0xaa, 0xbb, 0xcc, 0xdd]);
const apdu = transmitCalls[0];
assert.ok(apdu);
assert.strictEqual(apdu[4], 0x04); // Lc
assert.strictEqual(apdu[5], 0xaa);
});
it('should return signed data on success', async () => {
// Response with signed dynamic data
mockCard.transmit = async () => Buffer.from([
0x80, 0x08, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x90, 0x00,
]);
const response = await emv.internalAuthenticate([0x12, 0x34, 0x56, 0x78]);
assert.strictEqual(response.isOk(), true);
assert.strictEqual(response.buffer.length, 10);
});
it('should return conditions not satisfied error', async () => {
mockCard.transmit = async () => Buffer.from([0x69, 0x85]);
const response = await emv.internalAuthenticate([0x01, 0x02, 0x03, 0x04]);
assert.strictEqual(response.isOk(), false);
assert.strictEqual(response.sw1, 0x69);
assert.strictEqual(response.sw2, 0x85);
});
});
describe('T=0 protocol handling', () => {
it('should pass autoGetResponse: true to smartcard transmit', async () => {
await emv.selectPse();
assert.strictEqual(transmitCallsWithOptions.length, 1);
const call = transmitCallsWithOptions[0];
assert.ok(call);
assert.deepStrictEqual(call.options, { autoGetResponse: true });
});
it('should pass autoGetResponse for all APDU methods', async () => {
// Test multiple methods to ensure they all use autoGetResponse
await emv.selectPse();
await emv.selectApplication([0xa0, 0x00, 0x00, 0x00, 0x04]);
await emv.readRecord(1, 1);
await emv.getData(0x9f17);
await emv.getProcessingOptions();
// All calls should have autoGetResponse: true
for (const call of transmitCallsWithOptions) {
assert.deepStrictEqual(call.options, { autoGetResponse: true });
}
});
});
describe('parseAfl', async () => {
const { parseAfl } = await import('./emv-application.js');
it('should parse AFL entries from buffer', () => {
// AFL: SFI 1, records 1-3, 0 for SDA; SFI 2, records 1-1, 1 for SDA
const aflBuffer = Buffer.from([0x08, 0x01, 0x03, 0x00, 0x10, 0x01, 0x01, 0x01]);
const entries = parseAfl(aflBuffer);
assert.strictEqual(entries.length, 2);
assert.deepStrictEqual(entries[0], {
sfi: 1,
firstRecord: 1,
lastRecord: 3,
sdaRecords: 0,
});
assert.deepStrictEqual(entries[1], {
sfi: 2,
firstRecord: 1,
lastRecord: 1,
sdaRecords: 1,
});
});
it('should return empty array for empty buffer', () => {
const entries = parseAfl(Buffer.alloc(0));
assert.strictEqual(entries.length, 0);
});
});
describe('readAllRecords', () => {
it('should read all records from AFL entries', async () => {
let recordNum = 0;
mockCard.transmit = async () => {
recordNum++;
// Return different data for each record
return Buffer.from([0x70, 0x04, 0x5a, 0x02, recordNum, recordNum, 0x90, 0x00]);
};
const aflEntries = [
{ sfi: 1, firstRecord: 1, lastRecord: 2, sdaRecords: 0 },
{ sfi: 2, firstRecord: 1, lastRecord: 1, sdaRecords: 0 },
];
const records = await emv.readAllRecords(aflEntries);
assert.strictEqual(records.length, 3);
assert.strictEqual(records[0]?.sfi, 1);
assert.strictEqual(records[0]?.recordNumber, 1);
assert.strictEqual(records[1]?.sfi, 1);
assert.strictEqual(records[1]?.recordNumber, 2);
assert.strictEqual(records[2]?.sfi, 2);
assert.strictEqual(records[2]?.recordNumber, 1);
});
it('should accept raw AFL buffer', async () => {
mockCard.transmit = async () => Buffer.from([0x70, 0x02, 0x5a, 0x00, 0x90, 0x00]);
// SFI 1, records 1-1
const aflBuffer = Buffer.from([0x08, 0x01, 0x01, 0x00]);
const records = await emv.readAllRecords(aflBuffer);
assert.strictEqual(records.length, 1);
assert.strictEqual(records[0]?.sfi, 1);
});
it('should skip failed records', async () => {
let callCount = 0;
mockCard.transmit = async () => {
callCount++;
if (callCount === 2) {
// Second record fails
return Buffer.from([0x6a, 0x83]);
}
return Buffer.from([0x70, 0x02, 0x5a, 0x00, 0x90, 0x00]);
};
const aflEntries = [{ sfi: 1, firstRecord: 1, lastRecord: 3, sdaRecords: 0 }];
const records = await emv.readAllRecords(aflEntries);
// Should have 2 records (first and third), second failed
assert.strictEqual(records.length, 2);
});
it('should return empty array for empty AFL', async () => {
const records = await emv.readAllRecords([]);
assert.strictEqual(records.length, 0);
});
});
describe('discoverApplications', () => {
it('should discover applications from PSE', async () => {
let callCount = 0;
mockCard.transmit = async () => {
callCount++;
if (callCount === 1) {
// PSE response with SFI = 1
return Buffer.from([
0x6f, 0x15, 0x84, 0x0e, 0x31, 0x50, 0x41, 0x59, 0x2e, 0x53, 0x59, 0x53,
0x2e, 0x44, 0x44, 0x46, 0x30, 0x31, 0xa5, 0x03, 0x88, 0x01, 0x01, 0x90,
0x00,
]);
}
else if (callCount === 2) {
// Record with AID and label
return Buffer.from([
0x70, 0x15, 0x61, 0x13, 0x4f, 0x07, 0xa0, 0x00, 0x00, 0x00, 0x04, 0x10,
0x10, 0x50, 0x04, 0x56, 0x49, 0x53, 0x41, 0x87, 0x01, 0x01, 0x90, 0x00,
]);
}
// End of records
return Buffer.from([0x6a, 0x83]);
};
const result = await emv.discoverApplications();
assert.strictEqual(result.success, true);
assert.strictEqual(result.apps.length, 1);
assert.strictEqual(result.apps[0]?.aid, 'a0000000041010');
assert.strictEqual(result.apps[0]?.label, 'VISA');
assert.strictEqual(result.apps[0]?.priority, 1);
assert.strictEqual(result.sfi, 1);
});
it('should return empty apps when PSE fails', async () => {
mockCard.transmit = async () => Buffer.from([0x6a, 0x82]);
const result = await emv.discoverApplications();
assert.strictEqual(result.success, false);
assert.strictEqual(result.apps.length, 0);
});
it('should handle cards with multiple applications', async () => {
let callCount = 0;
mockCard.transmit = async () => {
callCount++;
if (callCount === 1) {
// PSE response
return Buffer.from([
0x6f, 0x15, 0x84, 0x0e, 0x31, 0x50, 0x41, 0x59, 0x2e, 0x53, 0x59, 0x53,
0x2e, 0x44, 0x44, 0x46, 0x30, 0x31, 0xa5, 0x03, 0x88, 0x01, 0x01, 0x90,
0x00,
]);
}
else if (callCount === 2) {
// First app: Visa
return Buffer.from([
0x70, 0x12, 0x4f, 0x07, 0xa0, 0x00, 0x00, 0x00, 0x04, 0x10, 0x10, 0x50,
0x04, 0x56, 0x49, 0x53, 0x41, 0x90, 0x00,
]);
}
else if (callCount === 3) {
// Second app: Mastercard
return Buffer.from([
0x70, 0x16, 0x4f, 0x07, 0xa0, 0x00, 0x00, 0x00, 0x04, 0x10, 0x10, 0x50,
0x0a, 0x4d, 0x61, 0x73, 0x74, 0x65, 0x72, 0x63, 0x61, 0x72, 0x64, 0x90,
0x00,
]);
}
return Buffer.from([0x6a, 0x83]);
};
const result = await emv.discoverApplications();
assert.strictEqual(result.success, true);
assert.strictEqual(result.apps.length, 2);
});
});
describe('parsePdol', async () => {
const { parsePdol } = await import('./emv-application.js');
it('should parse PDOL tag-length pairs', () => {
// PDOL: 9F66 (4 bytes) + 9F02 (6 bytes) + 9F37 (4 bytes)
const pdolBuffer = Buffer.from([0x9f, 0x66, 0x04, 0x9f, 0x02, 0x06, 0x9f, 0x37, 0x04]);
const entries = parsePdol(pdolBuffer);
assert.strictEqual(entries.length, 3);
assert.deepStrictEqual(entries[0], { tag: 0x9f66, length: 4 });
assert.deepStrictEqual(entries[1], { tag: 0x9f02, length: 6 });
assert.deepStrictEqual(entries[2], { tag: 0x9f37, length: 4 });
});
it('should parse single-byte tags', () => {
// PDOL: 9A (3 bytes) - Transaction Date
const pdolBuffer = Buffer.from([0x9a, 0x03]);
const entries = parsePdol(pdolBuffer);
assert.strictEqual(entries.length, 1);
assert.deepStrictEqual(entries[0], { tag: 0x9a, length: 3 });
});
});
describe('buildPdolData', async () => {
const { buildPdolData } = await import('./emv-application.js');
it('should build PDOL data from tag values', () => {
const pdolEntries = [
{ tag: 0x9f02, length: 6 }, // Amount
{ tag: 0x5f2a, length: 2 }, // Currency
];
const tagValues = new Map([
[0x9f02, Buffer.from([0x00, 0x00, 0x00, 0x00, 0x10, 0x00])], // 10.00
[0x5f2a, Buffer.from([0x08, 0x26])], // USD
]);
const result = buildPdolData(pdolEntries, tagValues);
assert.strictEqual(result.toString('hex'), '000000001000' + '0826');
});
it('should pad with zeros for missing tag values', () => {
const pdolEntries = [{ tag: 0x9f02, length: 6 }];
const tagValues = new Map();
const result = buildPdolData(pdolEntries, tagValues);
assert.strictEqual(result.toString('hex'), '000000000000');
});
it('should truncate values that are too long', () => {
const pdolEntries = [{ tag: 0x9f02, length: 3 }];
const tagValues = new Map([
[0x9f02, Buffer.from([0x00, 0x00, 0x00, 0x00, 0x10, 0x00])],
]);
const result = buildPdolData(pdolEntries, tagValues);
assert.strictEqual(result.length, 3);
});
});
describe('buildDefaultPdolData', async () => {
const { buildDefaultPdolData } = await import('./emv-application.js');
it('should build PDOL data with default values', () => {
const pdolEntries = [
{ tag: 0x9f02, length: 6 }, // Amount
{ tag: 0x5f2a, length: 2 }, // Currency
{ tag: 0x9a, length: 3 }, // Transaction Date
];
const result = buildDefaultPdolData(pdolEntries, {
amount: 1000,
currencyCode: 0x0840,
});
// Amount should be 000000001000 (1000 cents in BCD)
assert.strictEqual(result.subarray(0, 6).toString('hex'), '000000001000');
// Currency should be 0840 (USD)
assert.strictEqual(result.subarray(6, 8).toString('hex'), '0840');
// Date should be today's date (3 bytes)
assert.strictEqual(result.length, 11);
});
it('should allow custom overrides', () => {
const pdolEntries = [{ tag: 0x9f02, length: 6 }];
const customAmount = Buffer.from([0x00, 0x00, 0x00, 0x05, 0x00, 0x00]);
const result = buildDefaultPdolData(pdolEntries, {
amount: 1000,
currencyCode: 0x0840,
overrides: new Map([[0x9f02, customAmount]]),
});
assert.strictEqual(result.toString('hex'), '000000050000');
});
it('should use zeros for tags without defaults', () => {
const pdolEntries = [
{ tag: 0x9f99, length: 4 }, // Unknown tag
];
const result = buildDefaultPdolData(pdolEntries, {
amount: 1000,
currencyCode: 0x0840,
});
assert.strictEqual(result.toString('hex'), '00000000');
});
});
describe('buildDefaultCdolData', async () => {
const { buildDefaultCdolData } = await import('./emv-application.js');
it('should build CDOL data with common fields', () => {
const result = buildDefaultCdolData({
amount: 2500,
currencyCode: 0x0840,
});
// Should contain amount, other amount, country code, TVR, currency, date, type, unpredictable number
// 6 + 6 + 2 + 5 + 2 + 3 + 1 + 4 = 29 bytes
assert.strictEqual(result.length, 29);
// Amount should be first 6 bytes
assert.strictEqual(result.subarray(0, 6).toString('hex'), '000000002500');
});
it('should include transaction type', () => {
const result = buildDefaultCdolData({
amount: 1000,
currencyCode: 0x0840,
transactionType: 0x09, // Cashback
});
// Transaction type is at offset 24 (after amount 6 + other 6 + country 2 + tvr 5 + currency 2 + date 3)
assert.strictEqual(result[24], 0x09);
});
});
describe('performTransaction', () => {
it('should orchestrate full transaction flow', async () => {
// Set up mock responses for the transaction flow
let callCount = 0;
mockCard.transmit = async () => {
callCount++;
if (callCount === 1) {
// GPO response (Format 1): 80 len AIP(2) AFL(4)
// AIP: 1C00, AFL: SFI 1 (0x08) records 1-1
return Buffer.from([
0x80, 0x06, 0x1c, 0x00, 0x08, 0x01, 0x01, 0x00, 0x90, 0x00,
]);
}
else if (callCount === 2) {
// Read record response - proper TLV structure
// 70 len [5A len PAN]
return Buffer.from([
0x70, 0x0a, 0x5a, 0x08, 0x12, 0x34, 0x56, 0x78, 0x90, 0x12, 0x34, 0x56,
0x90, 0x00,
]);
}
else if (callCount === 3) {
// Generate AC response with cryptogram - proper TLV structure
// 9F27 (4 bytes: 2-byte tag + 1-byte len + 1-byte value) = 4
// 9F36 (5 bytes: 2-byte tag + 1-byte len + 2-byte value) = 5
// 9F26 (11 bytes: 2-byte tag + 1-byte len + 8-byte value) = 11
// Total = 20 bytes = 0x14
return Buffer.from([
0x77,
0x14, // 20 bytes
0x9f,
0x27,
0x01,
0x80, // CID: ARQC (4 bytes)
0x9f,
0x36,
0x02,
0x00,
0x01, // ATC: 1 (5 bytes)
0x9f,
0x26,
0x08,
0x12,
0x34,
0x56,
0x78,
0x9a,
0xbc,
0xde,
0xf0, // Cryptogram (11 bytes)
0x90,
0x00, // SW
]);
}
return Buffer.from([0x90, 0x00]);
};
const result = await emv.performTransaction({
amount: 1000,
currencyCode: 0x0840,
transactionType: 0x00,
});
assert.ok(result);
assert.strictEqual(result.success, true);
assert.strictEqual(result.cryptogramType, 'ARQC');
assert.ok(result.cryptogram);
assert.strictEqual(result.atc, 1);
});
it('should handle GPO failure', async () => {
mockCard.transmit = async () => Buffer.from([0x69, 0x85]); // Conditions not satisfied
const result = await emv.performTransaction({
amount: 1000,
currencyCode: 0x0840,
});
assert.strictEqual(result.success, false);
assert.ok(result.error);
});
});
describe('parseCvmList', async () => {
const { parseCvmList } = await import('./emv-application.js');
it('should parse CVM list with amount thresholds', () => {
// CVM List: X=1000, Y=5000, then rules
const buffer = Buffer.from([
0x00,
0x00,
0x03,
0xe8, // X = 1000
0x00,
0x00,
0x13,
0x88, // Y = 5000
0x02,
0x03, // Enciphered PIN online, if terminal supports CVM
0x1e,
0x03, // Signature, if terminal supports CVM
0x1f,
0x00, // No CVM, always
]);
const result = parseCvmList(buffer);
assert.strictEqual(result.amountX, 1000);
assert.strictEqual(result.amountY, 5000);
assert.strictEqual(result.rules.length, 3);
assert.strictEqual(result.rules[0]?.method, 'enciphered_pin_online');
assert.strictEqual(result.rules[0]?.condition, 'terminal_supports_cvm');
assert.strictEqual(result.rules[0]?.failIfUnsuccessful, true);
assert.strictEqual(result.rules[1]?.method, 'signature');
assert.strictEqual(result.rules[2]?.method, 'no_cvm');
});
it('should handle continue-on-fail flag', () => {
const buffer = Buffer.from([
0x00,
0x00,
0x00,
0x00, // X = 0
0x00,
0x00,
0x00,
0x00, // Y = 0
0x42,
0x00, // Enciphered PIN online + continue if fails, always
]);
const result = parseCvmList(buffer);
assert.strictEqual(result.rules[0]?.failIfUnsuccessful, false);
});
it('should return empty rules for buffer too short', () => {
const buffer = Buffer.from([0x00, 0x00, 0x00, 0x00]);
const result = parseCvmList(buffer);
assert.strictEqual(result.rules.length, 0);
});
});
describe('evaluateCvm', async () => {
const { parseCvmList, evaluateCvm } = await import('./emv-application.js');
it('should select first matching rule', () => {
const cvmList = parseCvmList(Buffer.from([
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x02,
0x03, // Enciphered PIN, if terminal supports
0x1e,
0x03, // Signature, if terminal supports
]));
const result = evaluateCvm(cvmList, { terminalSupportsCvm: true });
assert.strictEqual(result?.method, 'enciphered_pin_online');
});
it('should skip rules where condition not met', () => {
const cvmList = parseCvmList(Buffer.from([
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x02,
0x03, // Enciphered PIN, if terminal supports
0x1f,
0x00, // No CVM, always
]));
const result = evaluateCvm(cvmList, { terminalSupportsCvm: false });
assert.strictEqual(result?.method, 'no_cvm');
});
it('should handle amount threshold conditions', () => {
const cvmList = parseCvmList(Buffer.from([
0x00,
0x00,
0x03,
0xe8, // X = 1000
0x00,
0x00,
0x00,
0x00, // Y = 0
0x02,
0x07, // Enciphered PIN, if amount > X
0x1f,
0x00, // No CVM, always
]));
// Amount 500 is under X (1000), so PIN rule doesn't apply
const result1 = evaluateCvm(cvmList, { amount: 500 });
assert.strictEqual(result1?.method, 'no_cvm');
// Amount 1500 is over X, so PIN rule applies
const result2 = evaluateCvm(cvmList, { amount: 1500 });
assert.strictEqual(result2?.method, 'enciphered_pin_online');
});
it('should return undefined if no rules match', () => {
const cvmList = parseCvmList(Buffer.from([
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x02,
0x03, // Enciphered PIN, if terminal supports
]));
const result = evaluateCvm(cvmList, { terminalSupportsCvm: false });
assert.strictEqual(result, undefined);
});
});
describe('stringToBcd', async () => {
const { stringToBcd } = await import('./emv-application.js');
it('should encode "00" as 0x00', () => {
assert.strictEqual(stringToBcd('00'), 0x00);
});
it('should encode "12" as 0x12', () => {
assert.strictEqual(stringToBcd('12'), 0x12);
});
it('should encode "25" as 0x25', () => {
assert.strictEqual(stringToBcd('25'), 0x25);
});
it('should encode "99" as 0x99', () => {
assert.strictEqual(stringToBcd('99'), 0x99);
});
it('should encode "10" correctly (not as hex 16)', () => {
// This is the key test - parseInt('10', 16) would give 16 (0x10)
// but BCD encoding of "10" should be (1 << 4) | 0 = 0x10 = 16
// So for '10' specifically, both approaches give same result.
// Let's test '31' where they differ: parseInt('31', 16) = 49 (0x31)
// BCD '31' = (3 << 4) | 1 = 49 (0x31) - also same!
// The bug is subtle - it works for valid date digits 0-9.
// Test with explicit nibble check
const result = stringToBcd('31');
assert.strictEqual(result >> 4, 3, 'High nibble should be 3');
assert.strictEqual(result & 0x0f, 1, 'Low nibble should be 1');
});
it('should throw TypeError for empty string', () => {
assert.throws(() => stringToBcd(''), TypeError);
});
it('should throw TypeError for single character', () => {
assert.throws(() => stringToBcd('5'), TypeError);
});
it('should throw TypeError for string longer than 2 characters', () => {
assert.throws(() => stringToBcd('123'), TypeError);
});
it('should throw TypeError for non-digit characters', () => {
assert.throws(() => stringToBcd('ab'), TypeError);
assert.throws(() => stringToBcd('1a'), TypeError);
assert.throws(() => stringToBcd('a1'), TypeError);
});
});
describe('parseGpoResponseBuffer', async () => {
const { parseGpoResponseBuffer } = await import('./emv-application.js');
it('should parse Format 1 (tag 80) GPO response', () => {
// Format 1: 80 len AIP(2) AFL(4)
// AIP: 1C00, AFL: SFI 1 records 1-1
const buffer = Buffer.from([0x80, 0x06, 0x1c, 0x00, 0x08, 0x01, 0x01, 0x00]);
const result = parseGpoResponseBuffer(buffer);
assert.ok(result.aip);
assert.strictEqual(result.aip.toString('hex'), '1c00');
assert.strictEqual(result.afl.length, 1);
assert.strictEqual(result.afl[0]?.sfi, 1);
assert.strictEqual(result.afl[0]?.firstRecord, 1);
assert.strictEqual(result.afl[0]?.lastRecord, 1);
});
it('should parse Format 2 (tag 77) GPO response', () => {
// Format 2: 77 len [82 02 AIP] [94 04 AFL]
const buffer = Buffer.from([
0x77, 0x0a, 0x82, 0x02, 0x3c, 0x00, 0x94, 0x04, 0x08, 0x01, 0x02, 0x01,
]);
const result = parseGpoResponseBuffer(buffer);
assert.ok(result.aip);
assert.strictEqual(result.aip.toString('hex'), '3c00');
assert.strictEqual(result.afl.length, 1);
assert.strictEqual(result.afl[0]?.sfi, 1);
assert.strictEqual(result.afl[0]?.firstRecord, 1);
assert.strictEqual(result.afl[0]?.lastRecord, 2);
});
it('should return empty result for empty buffer', () => {
const result = parseGpoResponseBuffer(Buffer.alloc(0));
assert.strictEqual(result.aip, undefined);
assert.strictEqual(result.afl.length, 0);
});
it('should return empty result for unknown format', () => {
const buffer = Buffer.from([0x99, 0x02, 0x12, 0x34]);
const resu