UNPKG

emv

Version:

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

778 lines 32.2 kB
import { describe, it, mock } from 'node:test'; import assert from 'node:assert'; import { listReaders, waitForCard, selectPse, selectApp, listApps, readRecord, getData, cardInfo, dumpCard, verifyPin, processShellCommand, readPseApplications, } from './commands.js'; function createMockContext() { const outputs = []; const errors = []; return { ctx: { output: (msg) => outputs.push(msg), error: (msg) => errors.push(msg), readerName: undefined, format: undefined, verbose: undefined, }, outputs, errors, }; } describe('Commands', () => { describe('listReaders', () => { it('should return empty array when no readers available', async () => { const { ctx, outputs } = createMockContext(); const mockDevices = { listReaders: () => [], start: mock.fn(), stop: mock.fn(), on: mock.fn(), once: mock.fn(), }; const result = await listReaders(ctx, { devices: mockDevices }); assert.strictEqual(result, 0); assert.strictEqual(outputs.length, 1); assert.ok(outputs[0]?.includes('No readers')); }); it('should list available readers', async () => { const { ctx, outputs } = createMockContext(); const mockDevices = { listReaders: () => [ { name: 'Reader 1', state: 0, atr: null }, { name: 'Reader 2', state: 0, atr: null }, ], start: mock.fn(), stop: mock.fn(), on: mock.fn(), once: mock.fn(), }; const result = await listReaders(ctx, { devices: mockDevices }); assert.strictEqual(result, 0); assert.ok(outputs.some((o) => o.includes('Reader 1'))); assert.ok(outputs.some((o) => o.includes('Reader 2'))); }); it('should indicate when a card is present', async () => { const { ctx, outputs } = createMockContext(); const mockDevices = { listReaders: () => [ { name: 'Reader 1', state: 0x20, atr: Buffer.from([0x3b, 0x90]) }, // SCARD_STATE_PRESENT = 0x20 ], start: mock.fn(), stop: mock.fn(), on: mock.fn(), once: mock.fn(), }; const result = await listReaders(ctx, { devices: mockDevices }); assert.strictEqual(result, 0); assert.ok(outputs.some((o) => o.includes('card present'))); }); }); describe('waitForCard', () => { it('should wait for card and return ATR', async () => { const { ctx, outputs } = createMockContext(); const mockCard = { atr: Buffer.from([0x3b, 0x90, 0x00]), protocol: 1, connected: true, transmit: mock.fn(), }; let cardInsertedHandler; const mockDevices = { listReaders: () => [{ name: 'Test Reader', state: 0, atr: null }], start: mock.fn(() => { // Simulate card insertion after start setTimeout(() => { if (cardInsertedHandler) { cardInsertedHandler({ reader: { name: 'Test Reader' }, card: mockCard, }); } }, 10); }), stop: mock.fn(), on: mock.fn((event, handler) => { if (event === 'card-inserted') { cardInsertedHandler = handler; } }), once: mock.fn(), }; const result = await waitForCard(ctx, { devices: mockDevices, timeout: 1000 }); assert.strictEqual(result, 0); assert.ok(outputs.some((o) => o.includes('3b9000'))); }); it('should timeout when no card inserted', async () => { const { ctx, errors } = createMockContext(); const mockDevices = { listReaders: () => [{ name: 'Test Reader', state: 0, atr: null }], start: mock.fn(), stop: mock.fn(), on: mock.fn(), once: mock.fn(), }; const result = await waitForCard(ctx, { devices: mockDevices, timeout: 50 }); assert.strictEqual(result, 1); assert.ok(errors[0]?.includes('Timeout')); }); it('should filter by reader name when specified', async () => { const { ctx, outputs } = createMockContext(); ctx.readerName = 'Specific Reader'; const mockCard = { atr: Buffer.from([0x3b, 0x90]), protocol: 1, connected: true, transmit: mock.fn(), }; let cardInsertedHandler; const mockDevices = { listReaders: () => [ { name: 'Other Reader', state: 0, atr: null }, { name: 'Specific Reader', state: 0, atr: null }, ], start: mock.fn(() => { setTimeout(() => { if (cardInsertedHandler) { // First emit for wrong reader (should be ignored) cardInsertedHandler({ reader: { name: 'Other Reader' }, card: mockCard, }); // Then emit for correct reader cardInsertedHandler({ reader: { name: 'Specific Reader' }, card: mockCard, }); } }, 10); }), stop: mock.fn(), on: mock.fn((event, handler) => { if (event === 'card-inserted') { cardInsertedHandler = handler; } }), once: mock.fn(), }; const result = await waitForCard(ctx, { devices: mockDevices, timeout: 1000 }); assert.strictEqual(result, 0); assert.ok(outputs.some((o) => o.includes('Specific Reader'))); }); }); describe('selectPse', () => { it('should select PSE and show response', async () => { const { ctx, outputs } = createMockContext(); // PSE response with SFI const pseResponse = Buffer.from([ 0x6f, 0x1a, // FCI Template 0x84, 0x0e, 0x31, 0x50, 0x41, 0x59, 0x2e, 0x53, 0x59, 0x53, 0x2e, 0x44, 0x44, 0x46, 0x30, 0x31, // DF Name 0xa5, 0x08, // FCI Proprietary Template 0x88, 0x01, 0x01, // SFI 0x5f, 0x2d, 0x02, 0x65, 0x6e, // Language preference 0x90, 0x00, // SW ]); const mockEmv = { selectPse: mock.fn(() => Promise.resolve({ buffer: pseResponse.subarray(0, -2), sw1: 0x90, sw2: 0x00, isOk: () => true, })), }; const result = await selectPse(ctx, { emv: mockEmv }); assert.strictEqual(result, 0); assert.ok(outputs.some((o) => o.includes('PSE selected'))); }); it('should show error when PSE selection fails', async () => { const { ctx, errors } = createMockContext(); const mockEmv = { selectPse: mock.fn(() => Promise.resolve({ buffer: Buffer.alloc(0), sw1: 0x6a, sw2: 0x82, isOk: () => false, })), }; const result = await selectPse(ctx, { emv: mockEmv }); assert.strictEqual(result, 1); assert.ok(errors.some((o) => o.includes('failed'))); }); }); describe('selectApp', () => { it('should select application by AID', async () => { const { ctx, outputs } = createMockContext(); const mockEmv = { selectApplication: mock.fn(() => Promise.resolve({ buffer: Buffer.from([ 0x6f, 0x10, 0x84, 0x07, 0xa0, 0x00, 0x00, 0x00, 0x04, 0x10, 0x10, ]), sw1: 0x90, sw2: 0x00, isOk: () => true, })), }; const result = await selectApp(ctx, 'a0000000041010', { emv: mockEmv }); assert.strictEqual(result, 0); assert.ok(outputs.some((o) => o.includes('Application selected'))); }); it('should show error for invalid AID format', async () => { const { ctx, errors } = createMockContext(); const result = await selectApp(ctx, 'invalid', {}); assert.strictEqual(result, 1); assert.ok(errors.some((o) => o.includes('Invalid AID'))); }); it('should show error when application not found', async () => { const { ctx, errors } = createMockContext(); const mockEmv = { selectApplication: mock.fn(() => Promise.resolve({ buffer: Buffer.alloc(0), sw1: 0x6a, sw2: 0x82, isOk: () => false, })), }; const result = await selectApp(ctx, 'a0000000041010', { emv: mockEmv }); assert.strictEqual(result, 1); assert.ok(errors.some((o) => o.includes('failed'))); }); }); describe('listApps', () => { it('should list applications from PSE', async () => { const { ctx, outputs } = createMockContext(); // PSE response with SFI const pseResponse = { buffer: Buffer.from([ 0x6f, 0x1a, 0x84, 0x0e, 0x31, 0x50, 0x41, 0x59, 0x2e, 0x53, 0x59, 0x53, 0x2e, 0x44, 0x44, 0x46, 0x30, 0x31, 0xa5, 0x08, 0x88, 0x01, 0x01, 0x5f, 0x2d, 0x02, 0x65, 0x6e, ]), sw1: 0x90, sw2: 0x00, isOk: () => true, }; // Record with AID const recordResponse = { buffer: Buffer.from([ 0x70, 0x1a, // Record template 0x61, 0x18, // Application template 0x4f, 0x07, 0xa0, 0x00, 0x00, 0x00, 0x04, 0x10, 0x10, // AID 0x50, 0x0a, 0x4d, 0x61, 0x73, 0x74, 0x65, 0x72, 0x43, 0x61, 0x72, 0x64, // Label 0x87, 0x01, 0x01, // Priority ]), sw1: 0x90, sw2: 0x00, isOk: () => true, }; // Empty record (end of list) const emptyResponse = { buffer: Buffer.alloc(0), sw1: 0x6a, sw2: 0x83, isOk: () => false, }; let readRecordCalls = 0; const mockEmv = { selectPse: mock.fn(() => Promise.resolve(pseResponse)), readRecord: mock.fn(() => { readRecordCalls++; if (readRecordCalls === 1) { return Promise.resolve(recordResponse); } return Promise.resolve(emptyResponse); }), }; const result = await listApps(ctx, { emv: mockEmv }); assert.strictEqual(result, 0); assert.ok(outputs.some((o) => o.includes('a0000000041010'))); }); it('should show message when no applications found', async () => { const { ctx, outputs } = createMockContext(); const pseResponse = { buffer: Buffer.from([ 0x6f, 0x1a, 0x84, 0x0e, 0x31, 0x50, 0x41, 0x59, 0x2e, 0x53, 0x59, 0x53, 0x2e, 0x44, 0x44, 0x46, 0x30, 0x31, 0xa5, 0x08, 0x88, 0x01, 0x01, 0x5f, 0x2d, 0x02, 0x65, 0x6e, ]), sw1: 0x90, sw2: 0x00, isOk: () => true, }; const emptyResponse = { buffer: Buffer.alloc(0), sw1: 0x6a, sw2: 0x83, isOk: () => false, }; const mockEmv = { selectPse: mock.fn(() => Promise.resolve(pseResponse)), readRecord: mock.fn(() => Promise.resolve(emptyResponse)), }; const result = await listApps(ctx, { emv: mockEmv }); assert.strictEqual(result, 0); assert.ok(outputs.some((o) => o.includes('No applications'))); }); }); describe('readRecord', () => { it('should read a record and display data', async () => { const { ctx, outputs } = createMockContext(); const mockEmv = { readRecord: mock.fn(() => Promise.resolve({ buffer: Buffer.from([ 0x70, 0x08, 0x5a, 0x06, 0x12, 0x34, 0x56, 0x78, 0x90, 0x12, ]), sw1: 0x90, sw2: 0x00, isOk: () => true, })), }; const result = await readRecord(ctx, 1, 1, { emv: mockEmv }); assert.strictEqual(result, 0); assert.ok(outputs.some((o) => o.includes('Record 1'))); }); it('should show error when record not found', async () => { const { ctx, errors } = createMockContext(); const mockEmv = { readRecord: mock.fn(() => Promise.resolve({ buffer: Buffer.alloc(0), sw1: 0x6a, sw2: 0x83, isOk: () => false, })), }; const result = await readRecord(ctx, 1, 1, { emv: mockEmv }); assert.strictEqual(result, 1); assert.ok(errors.some((o) => o.includes('failed'))); }); it('should validate SFI range', async () => { const { ctx, errors } = createMockContext(); const result = await readRecord(ctx, 0, 1, {}); assert.strictEqual(result, 1); assert.ok(errors.some((o) => o.includes('SFI'))); }); it('should validate record number range', async () => { const { ctx, errors } = createMockContext(); const result = await readRecord(ctx, 1, 300, {}); assert.strictEqual(result, 1); assert.ok(errors.some((o) => o.includes('Record'))); }); }); describe('getData', () => { it('should get data by tag and display result', async () => { const { ctx, outputs } = createMockContext(); // PIN try counter response: 9F17 01 03 const mockEmv = { getData: mock.fn(() => Promise.resolve({ buffer: Buffer.from([0x9f, 0x17, 0x01, 0x03]), sw1: 0x90, sw2: 0x00, isOk: () => true, })), }; const result = await getData(ctx, '9f17', { emv: mockEmv }); assert.strictEqual(result, 0); assert.ok(outputs.some((o) => o.includes('9f17'))); }); it('should show error when data not found', async () => { const { ctx, errors } = createMockContext(); const mockEmv = { getData: mock.fn(() => Promise.resolve({ buffer: Buffer.alloc(0), sw1: 0x6a, sw2: 0x88, isOk: () => false, })), }; const result = await getData(ctx, '9f17', { emv: mockEmv }); assert.strictEqual(result, 1); assert.ok(errors.some((o) => o.includes('failed'))); }); it('should validate tag format', async () => { const { ctx, errors } = createMockContext(); const result = await getData(ctx, 'invalid', {}); assert.strictEqual(result, 1); assert.ok(errors.some((o) => o.includes('Invalid tag'))); }); it('should accept 1-byte tags', async () => { const { ctx, outputs } = createMockContext(); const mockEmv = { getData: mock.fn(() => Promise.resolve({ buffer: Buffer.from([0x9c, 0x01, 0x00]), sw1: 0x90, sw2: 0x00, isOk: () => true, })), }; const result = await getData(ctx, '9c', { emv: mockEmv }); assert.strictEqual(result, 0); assert.ok(outputs.some((o) => o.includes('9c'))); }); }); describe('cardInfo', () => { it('should display ATR and applications', async () => { const { ctx, outputs } = createMockContext(); ctx.format = 'text'; const pseResponse = { buffer: Buffer.from([ 0x6f, 0x1a, 0x84, 0x0e, 0x31, 0x50, 0x41, 0x59, 0x2e, 0x53, 0x59, 0x53, 0x2e, 0x44, 0x44, 0x46, 0x30, 0x31, 0xa5, 0x08, 0x88, 0x01, 0x01, 0x5f, 0x2d, 0x02, 0x65, 0x6e, ]), sw1: 0x90, sw2: 0x00, isOk: () => true, }; const recordResponse = { buffer: Buffer.from([ 0x70, 0x1a, 0x61, 0x18, 0x4f, 0x07, 0xa0, 0x00, 0x00, 0x00, 0x04, 0x10, 0x10, 0x50, 0x0a, 0x4d, 0x61, 0x73, 0x74, 0x65, 0x72, 0x43, 0x61, 0x72, 0x64, 0x87, 0x01, 0x01, ]), sw1: 0x90, sw2: 0x00, isOk: () => true, }; const emptyResponse = { buffer: Buffer.alloc(0), sw1: 0x6a, sw2: 0x83, isOk: () => false, }; let readRecordCalls = 0; const mockEmv = { getAtr: () => '3b9000', getReaderName: () => 'Test Reader', selectPse: mock.fn(() => Promise.resolve(pseResponse)), readRecord: mock.fn(() => { readRecordCalls++; if (readRecordCalls === 1) { return Promise.resolve(recordResponse); } return Promise.resolve(emptyResponse); }), }; const result = await cardInfo(ctx, { emv: mockEmv }); assert.strictEqual(result, 0); assert.ok(outputs.some((o) => o.includes('3b9000'))); assert.ok(outputs.some((o) => o.includes('Test Reader'))); }); it('should output JSON when format is json', async () => { const { ctx, outputs } = createMockContext(); ctx.format = 'json'; const pseResponse = { buffer: Buffer.from([ 0x6f, 0x1a, 0x84, 0x0e, 0x31, 0x50, 0x41, 0x59, 0x2e, 0x53, 0x59, 0x53, 0x2e, 0x44, 0x44, 0x46, 0x30, 0x31, 0xa5, 0x08, 0x88, 0x01, 0x01, 0x5f, 0x2d, 0x02, 0x65, 0x6e, ]), sw1: 0x90, sw2: 0x00, isOk: () => true, }; const recordResponse = { buffer: Buffer.from([ 0x70, 0x1a, 0x61, 0x18, 0x4f, 0x07, 0xa0, 0x00, 0x00, 0x00, 0x04, 0x10, 0x10, 0x50, 0x0a, 0x4d, 0x61, 0x73, 0x74, 0x65, 0x72, 0x43, 0x61, 0x72, 0x64, 0x87, 0x01, 0x01, ]), sw1: 0x90, sw2: 0x00, isOk: () => true, }; const emptyResponse = { buffer: Buffer.alloc(0), sw1: 0x6a, sw2: 0x83, isOk: () => false, }; let readRecordCalls = 0; const mockEmv = { getAtr: () => '3b9000', getReaderName: () => 'Test Reader', selectPse: mock.fn(() => Promise.resolve(pseResponse)), readRecord: mock.fn(() => { readRecordCalls++; if (readRecordCalls === 1) { return Promise.resolve(recordResponse); } return Promise.resolve(emptyResponse); }), }; const result = await cardInfo(ctx, { emv: mockEmv }); assert.strictEqual(result, 0); // Should be valid JSON const json = JSON.parse(outputs[0] ?? ''); assert.strictEqual(json.atr, '3b9000'); }); }); describe('dumpCard', () => { it('should dump all records', async () => { const { ctx, outputs } = createMockContext(); const pseResponse = { buffer: Buffer.from([ 0x6f, 0x1a, 0x84, 0x0e, 0x31, 0x50, 0x41, 0x59, 0x2e, 0x53, 0x59, 0x53, 0x2e, 0x44, 0x44, 0x46, 0x30, 0x31, 0xa5, 0x08, 0x88, 0x01, 0x01, 0x5f, 0x2d, 0x02, 0x65, 0x6e, ]), sw1: 0x90, sw2: 0x00, isOk: () => true, }; const recordResponse = { buffer: Buffer.from([0x70, 0x04, 0x5a, 0x02, 0x12, 0x34]), sw1: 0x90, sw2: 0x00, isOk: () => true, }; const emptyResponse = { buffer: Buffer.alloc(0), sw1: 0x6a, sw2: 0x83, isOk: () => false, }; let readRecordCalls = 0; const mockEmv = { getAtr: () => '3b9000', selectPse: mock.fn(() => Promise.resolve(pseResponse)), readRecord: mock.fn(() => { readRecordCalls++; if (readRecordCalls <= 2) { return Promise.resolve(recordResponse); } return Promise.resolve(emptyResponse); }), }; const result = await dumpCard(ctx, { emv: mockEmv }); assert.strictEqual(result, 0); assert.ok(outputs.some((o) => o.includes('SFI'))); }); }); describe('processShellCommand', () => { it('should return help text for help command', async () => { const { ctx, outputs } = createMockContext(); const result = await processShellCommand(ctx, 'help', {}); assert.strictEqual(result.action, 'continue'); assert.ok(outputs.some((o) => o.includes('Commands'))); }); it('should return exit action for quit command', async () => { const { ctx } = createMockContext(); const result = await processShellCommand(ctx, 'quit', {}); assert.strictEqual(result.action, 'exit'); }); it('should return exit action for exit command', async () => { const { ctx } = createMockContext(); const result = await processShellCommand(ctx, 'exit', {}); assert.strictEqual(result.action, 'exit'); }); it('should handle select-pse command', async () => { const { ctx, outputs } = createMockContext(); const mockEmv = { selectPse: mock.fn(() => Promise.resolve({ buffer: Buffer.from([0x6f, 0x10]), sw1: 0x90, sw2: 0x00, isOk: () => true, })), }; const result = await processShellCommand(ctx, 'select-pse', { emv: mockEmv }); assert.strictEqual(result.action, 'continue'); assert.ok(outputs.some((o) => o.includes('PSE selected'))); }); it('should handle select-app command with AID', async () => { const { ctx, outputs } = createMockContext(); const mockEmv = { selectApplication: mock.fn(() => Promise.resolve({ buffer: Buffer.from([0x6f, 0x10]), sw1: 0x90, sw2: 0x00, isOk: () => true, })), }; const result = await processShellCommand(ctx, 'select-app a0000000041010', { emv: mockEmv, }); assert.strictEqual(result.action, 'continue'); assert.ok(outputs.some((o) => o.includes('Application selected'))); }); it('should handle read-record command', async () => { const { ctx, outputs } = createMockContext(); const mockEmv = { readRecord: mock.fn(() => Promise.resolve({ buffer: Buffer.from([0x70, 0x04]), sw1: 0x90, sw2: 0x00, isOk: () => true, })), }; const result = await processShellCommand(ctx, 'read-record 1 1', { emv: mockEmv }); assert.strictEqual(result.action, 'continue'); assert.ok(outputs.some((o) => o.includes('Record 1'))); }); it('should show error for unknown command', async () => { const { ctx, errors } = createMockContext(); const result = await processShellCommand(ctx, 'unknown-cmd', {}); assert.strictEqual(result.action, 'continue'); assert.ok(errors.some((o) => o.includes('Unknown command'))); }); it('should ignore empty input', async () => { const { ctx, outputs, errors } = createMockContext(); const result = await processShellCommand(ctx, '', {}); assert.strictEqual(result.action, 'continue'); assert.strictEqual(outputs.length, 0); assert.strictEqual(errors.length, 0); }); }); describe('verifyPin', () => { it('should verify PIN successfully', async () => { const { ctx, outputs } = createMockContext(); const mockEmv = { verifyPin: mock.fn(() => Promise.resolve({ buffer: Buffer.from([]), sw1: 0x90, sw2: 0x00, isOk: () => true, })), }; const result = await verifyPin(ctx, '1234', { emv: mockEmv }); assert.strictEqual(result, 0); assert.ok(outputs.some((o) => o.includes('verified'))); }); it('should report wrong PIN with remaining attempts', async () => { const { ctx, errors } = createMockContext(); const mockEmv = { verifyPin: mock.fn(() => Promise.resolve({ buffer: Buffer.from([]), sw1: 0x63, sw2: 0xc2, // 2 attempts remaining isOk: () => false, })), }; const result = await verifyPin(ctx, '1234', { emv: mockEmv }); assert.strictEqual(result, 1); assert.ok(errors.some((o) => o.includes('Wrong PIN'))); assert.ok(errors.some((o) => o.includes('2'))); }); it('should report PIN blocked', async () => { const { ctx, errors } = createMockContext(); const mockEmv = { verifyPin: mock.fn(() => Promise.resolve({ buffer: Buffer.from([]), sw1: 0x69, sw2: 0x83, isOk: () => false, })), }; const result = await verifyPin(ctx, '1234', { emv: mockEmv }); assert.strictEqual(result, 1); assert.ok(errors.some((o) => o.includes('blocked'))); }); it('should validate PIN format', async () => { const { ctx, errors } = createMockContext(); const result = await verifyPin(ctx, '12', { emv: {} }); assert.strictEqual(result, 1); assert.ok(errors.some((o) => o.includes('4-12 digits'))); }); }); describe('readPseApplications', () => { it('should return applications from PSE records', async () => { const mockEmv = { selectPse: mock.fn(() => Promise.resolve({ buffer: Buffer.from([0x6f, 0x05, 0x88, 0x01, 0x01, 0x90, 0x00]), sw1: 0x90, sw2: 0x00, isOk: () => true, })), readRecord: mock.fn((_sfi, record) => { if (record === 1) { // Record with AID and label return Promise.resolve({ buffer: Buffer.from([ 0x70, 0x12, 0x4f, 0x07, 0xa0, 0x00, 0x00, 0x00, 0x04, 0x10, 0x10, 0x50, 0x04, 0x56, 0x49, 0x53, 0x41, 0x87, 0x01, 0x01, ]), sw1: 0x90, sw2: 0x00, isOk: () => true, }); } return Promise.resolve({ buffer: Buffer.from([]), sw1: 0x6a, sw2: 0x83, isOk: () => false, }); }), }; const result = await readPseApplications({ emv: mockEmv }); assert.strictEqual(result.pseOk, true); assert.strictEqual(result.apps.length, 1); assert.strictEqual(result.apps[0]?.aid, 'a0000000041010'); assert.strictEqual(result.apps[0]?.label, 'VISA'); assert.strictEqual(result.records.length, 1); assert.strictEqual(result.records[0]?.record, 1); }); it('should return empty apps when PSE fails', async () => { const mockEmv = { selectPse: mock.fn(() => Promise.resolve({ buffer: Buffer.from([]), sw1: 0x6a, sw2: 0x82, isOk: () => false, })), }; const result = await readPseApplications({ emv: mockEmv }); assert.strictEqual(result.pseOk, false); assert.strictEqual(result.apps.length, 0); }); it('should return empty apps when emv is not available', async () => { const result = await readPseApplications({}); assert.strictEqual(result.pseOk, false); assert.strictEqual(result.apps.length, 0); }); }); }); //# sourceMappingURL=commands.test.js.map