emv
Version:
EMV / Chip and PIN CLI and library for PC/SC card readers
520 lines • 17 kB
JavaScript
/**
* CLI command implementations
*/
import { findTagInBuffer } from './emv-tags.js';
const SCARD_STATE_PRESENT = 0x20;
/**
* List available PC/SC readers
*/
export async function listReaders(ctx, options = {}) {
const devices = options.devices ?? (await createDevices());
try {
devices.start();
// Small delay to allow readers to be detected
await new Promise((resolve) => setTimeout(resolve, 100));
const readers = devices.listReaders();
if (readers.length === 0) {
ctx.output('No readers found');
return 0;
}
ctx.output(`Found ${String(readers.length)} reader(s):\n`);
for (const reader of readers) {
const hasCard = (reader.state & SCARD_STATE_PRESENT) !== 0;
const cardStatus = hasCard ? ' (card present)' : '';
ctx.output(` ${reader.name}${cardStatus}`);
if (hasCard && reader.atr && ctx.verbose) {
ctx.output(` ATR: ${reader.atr.toString('hex')}`);
}
}
return 0;
}
finally {
devices.stop();
}
}
/**
* Wait for a card to be inserted
*/
export async function waitForCard(ctx, options = {}) {
const devices = options.devices ?? (await createDevices());
const timeout = options.timeout ?? 30000;
return new Promise((resolve) => {
const timeoutId = setTimeout(() => {
devices.stop();
ctx.error('Timeout waiting for card');
resolve(1);
}, timeout);
const handleCardInserted = (event) => {
// Filter by reader name if specified
if (ctx.readerName && event.reader.name !== ctx.readerName) {
return; // Ignore cards in other readers
}
clearTimeout(timeoutId);
devices.stop();
ctx.output(`Card inserted in: ${event.reader.name}`);
if (event.card.atr) {
ctx.output(`ATR: ${event.card.atr.toString('hex')}`);
}
resolve(0);
};
devices.on('card-inserted', handleCardInserted);
ctx.output('Waiting for card...');
if (ctx.readerName) {
ctx.output(`Using reader: ${ctx.readerName}`);
}
devices.start();
});
}
/**
* Create a Devices instance (lazy import to avoid loading native module in tests)
*/
async function createDevices() {
const { Devices } = await import('smartcard');
return new Devices();
}
/**
* Format status word for display
*/
function formatSw(sw1, sw2) {
return `SW: ${sw1.toString(16).padStart(2, '0').toUpperCase()}${sw2.toString(16).padStart(2, '0').toUpperCase()}`;
}
/**
* Select Payment System Environment
*/
export async function selectPse(ctx, options = {}) {
const emv = options.emv;
if (!emv?.selectPse) {
ctx.error('EMV application not available');
return 1;
}
const response = await emv.selectPse();
if (response.isOk()) {
ctx.output('PSE selected successfully');
if (ctx.verbose) {
ctx.output(`Response: ${response.buffer.toString('hex')}`);
}
return 0;
}
else {
ctx.error(`PSE selection failed - ${formatSw(response.sw1, response.sw2)}`);
return 1;
}
}
/**
* Parse hex string to buffer
*/
function parseHexAid(aid) {
// Remove any spaces or dashes
const cleaned = aid.replace(/[\s-]/g, '');
// Check valid hex string
if (!/^[0-9a-fA-F]+$/.test(cleaned)) {
return null;
}
// Must be even length (2 chars per byte)
if (cleaned.length % 2 !== 0) {
return null;
}
// AID must be 5-16 bytes
const length = cleaned.length / 2;
if (length < 5 || length > 16) {
return null;
}
return Buffer.from(cleaned, 'hex');
}
/**
* Select application by AID
*/
export async function selectApp(ctx, aidHex, options = {}) {
const aidBuffer = parseHexAid(aidHex);
if (!aidBuffer) {
ctx.error('Invalid AID format. AID must be 5-16 bytes in hex (e.g., a0000000041010)');
return 1;
}
const emv = options.emv;
if (!emv?.selectApplication) {
ctx.error('EMV application not available');
return 1;
}
const response = await emv.selectApplication(aidBuffer);
if (response.isOk()) {
ctx.output(`Application selected: ${aidHex}`);
if (ctx.verbose) {
ctx.output(`Response: ${response.buffer.toString('hex')}`);
}
return 0;
}
else {
ctx.error(`Application selection failed - ${formatSw(response.sw1, response.sw2)}`);
return 1;
}
}
/**
* Read applications from Payment System Environment
* This is a shared helper used by listApps, cardInfo, and dumpCard.
* Uses EmvApplication.discoverApplications() for app discovery, with additional
* record reading for dump functionality.
*/
export async function readPseApplications(options = {}) {
const emv = options.emv;
if (!emv?.discoverApplications) {
// Fallback for mocks without discoverApplications
if (!emv?.selectPse || !emv.readRecord) {
return { pseOk: false, pseBuffer: Buffer.alloc(0), sfi: 1, apps: [], records: [] };
}
// Use the old implementation for backward compatibility with tests
const pseResponse = await emv.selectPse();
if (!pseResponse.isOk()) {
return { pseOk: false, pseBuffer: Buffer.alloc(0), sfi: 1, apps: [], records: [] };
}
const sfiData = findTagInBuffer(pseResponse.buffer, 0x88);
const sfi = sfiData?.[0] ?? 1;
const apps = [];
const records = [];
for (let record = 1; record <= 10; record++) {
const response = await emv.readRecord(sfi, record);
if (!response.isOk())
break;
records.push({
sfi,
record,
data: response.buffer.toString('hex'),
});
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 { pseOk: true, pseBuffer: pseResponse.buffer, sfi, apps, records };
}
// Use the new discoverApplications method
const result = await emv.discoverApplications();
if (!result.success) {
return { pseOk: false, pseBuffer: Buffer.alloc(0), sfi: 1, apps: [], records: [] };
}
// Convert DiscoveredApp to PseAppInfo (they have the same shape)
const apps = result.apps.map((app) => ({
aid: app.aid,
label: app.label,
priority: app.priority,
}));
// For dump functionality, we need to re-read records to get raw data
// The discoverApplications method doesn't expose raw records
const records = [];
if (emv.readRecord) {
for (let record = 1; record <= 10; record++) {
const response = await emv.readRecord(result.sfi, record);
if (!response.isOk())
break;
records.push({
sfi: result.sfi,
record,
data: response.buffer.toString('hex'),
});
}
}
return {
pseOk: true,
pseBuffer: result.pseBuffer ?? Buffer.alloc(0),
sfi: result.sfi,
apps,
records,
};
}
/**
* List applications on card from PSE
*/
export async function listApps(ctx, options = {}) {
const result = await readPseApplications(options);
if (!result.pseOk) {
ctx.error('EMV application not available or PSE selection failed');
return 1;
}
if (result.apps.length === 0) {
ctx.output('No applications found on card');
return 0;
}
ctx.output(`Found ${String(result.apps.length)} application(s):\n`);
for (const app of result.apps) {
const labelStr = app.label ? ` - ${app.label}` : '';
const priorityStr = app.priority !== undefined ? ` (priority: ${String(app.priority)})` : '';
ctx.output(` ${app.aid}${labelStr}${priorityStr}`);
}
return 0;
}
/**
* Read a record from an SFI
*/
export async function readRecord(ctx, sfi, record, options = {}) {
// Validate SFI
if (!Number.isInteger(sfi) || sfi < 1 || sfi > 30) {
ctx.error('SFI must be an integer between 1 and 30');
return 1;
}
// Validate record number
if (!Number.isInteger(record) || record < 0 || record > 255) {
ctx.error('Record number must be an integer between 0 and 255');
return 1;
}
const emv = options.emv;
if (!emv?.readRecord) {
ctx.error('EMV application not available');
return 1;
}
const response = await emv.readRecord(sfi, record);
if (response.isOk()) {
ctx.output(`Record ${String(record)} from SFI ${String(sfi)}:`);
ctx.output(`Data: ${response.buffer.toString('hex')}`);
return 0;
}
else {
ctx.error(`Read record failed - ${formatSw(response.sw1, response.sw2)}`);
return 1;
}
}
/**
* Parse hex tag string to number
*/
function parseHexTag(tagStr) {
// Remove any 0x prefix
const cleaned = tagStr.replace(/^0x/i, '').toLowerCase();
// Check valid hex string (1-4 hex chars for 1-2 byte tags)
if (!/^[0-9a-f]{1,4}$/.test(cleaned)) {
return null;
}
return parseInt(cleaned, 16);
}
/**
* Get data by EMV tag
*/
export async function getData(ctx, tagStr, options = {}) {
const tag = parseHexTag(tagStr);
if (tag === null) {
ctx.error('Invalid tag format. Tag must be 1-2 bytes in hex (e.g., 9f17)');
return 1;
}
const emv = options.emv;
if (!emv?.getData) {
ctx.error('EMV application not available');
return 1;
}
const response = await emv.getData(tag);
if (response.isOk()) {
const tagHex = tagStr.toLowerCase();
ctx.output(`Tag ${tagHex}:`);
ctx.output(`Data: ${response.buffer.toString('hex')}`);
return 0;
}
else {
ctx.error(`Get data failed - ${formatSw(response.sw1, response.sw2)}`);
return 1;
}
}
/**
* Verify PIN
*/
export async function verifyPin(ctx, pin, options = {}) {
// Validate PIN format
if (!/^\d{4,12}$/.test(pin)) {
ctx.error('Invalid PIN format. PIN must be 4-12 digits.');
return 1;
}
const emv = options.emv;
if (!emv?.verifyPin) {
ctx.error('EMV application not available');
return 1;
}
const response = await emv.verifyPin(pin);
if (response.isOk()) {
ctx.output('PIN verified successfully');
return 0;
}
else if (response.sw1 === 0x63 && (response.sw2 & 0xf0) === 0xc0) {
const attemptsLeft = response.sw2 & 0x0f;
ctx.error(`Wrong PIN. ${String(attemptsLeft)} attempt(s) remaining.`);
return 1;
}
else if (response.sw1 === 0x69 && response.sw2 === 0x83) {
ctx.error('PIN is blocked. Card cannot be used.');
return 1;
}
else {
ctx.error(`PIN verification failed - ${formatSw(response.sw1, response.sw2)}`);
return 1;
}
}
/**
* Show card information
*/
export async function cardInfo(ctx, options = {}) {
const emv = options.emv;
if (!emv?.getAtr || !emv.getReaderName) {
ctx.error('EMV application not available');
return 1;
}
const pseResult = await readPseApplications(options);
const apps = pseResult.apps.map((app) => ({ aid: app.aid, label: app.label }));
// Output based on format
if (ctx.format === 'json') {
const result = {
reader: emv.getReaderName(),
atr: emv.getAtr(),
applications: apps,
};
ctx.output(JSON.stringify(result, null, 2));
}
else {
// Text format (default)
ctx.output('Card Information:');
ctx.output(` Reader: ${emv.getReaderName()}`);
ctx.output(` ATR: ${emv.getAtr()}`);
ctx.output('');
if (apps.length > 0) {
ctx.output('Applications:');
for (const app of apps) {
const labelStr = app.label ? ` (${app.label})` : '';
ctx.output(` ${app.aid}${labelStr}`);
}
}
else if (pseResult.pseOk) {
ctx.output('No applications found');
}
else {
ctx.output('PSE not available');
}
}
return 0;
}
/**
* Dump all readable data from card
*/
export async function dumpCard(ctx, options = {}) {
const emv = options.emv;
if (!emv?.getAtr) {
ctx.error('EMV application not available');
return 1;
}
const pseResult = await readPseApplications(options);
const pseHex = pseResult.pseBuffer.toString('hex');
// Output based on format
if (ctx.format === 'json') {
const result = {
atr: emv.getAtr(),
pse: pseHex,
records: pseResult.records,
};
ctx.output(JSON.stringify(result, null, 2));
}
else {
// Text format (default)
ctx.output('EMV Card Dump');
ctx.output('=============');
ctx.output('');
ctx.output(`ATR: ${emv.getAtr()}`);
ctx.output('');
if (pseResult.pseOk) {
ctx.output('PSE Response:');
ctx.output(` ${pseHex}`);
ctx.output('');
ctx.output(`Reading SFI ${String(pseResult.sfi)}:`);
for (const rec of pseResult.records) {
ctx.output(` Record ${String(rec.record)}: ${rec.data}`);
}
}
else {
ctx.output('PSE selection failed');
}
}
return 0;
}
/**
* Show shell help text
*/
function showShellHelp(ctx) {
ctx.output('Available Commands:');
ctx.output(' help Show this help message');
ctx.output(' select-pse Select Payment System Environment');
ctx.output(' select-app <aid> Select application by AID');
ctx.output(' read-record <sfi> <rec> Read a record from SFI');
ctx.output(' get-data <tag> Get data by EMV tag');
ctx.output(' list-apps List applications on card');
ctx.output(' info Show card information');
ctx.output(' quit, exit Exit interactive mode');
}
/**
* Process a command in the interactive shell
*/
export async function processShellCommand(ctx, input, options = {}) {
const trimmed = input.trim();
if (!trimmed) {
return { action: 'continue' };
}
const parts = trimmed.split(/\s+/);
const cmd = parts[0];
const args = parts.slice(1);
switch (cmd) {
case 'help':
case '?':
showShellHelp(ctx);
return { action: 'continue' };
case 'quit':
case 'exit':
return { action: 'exit' };
case 'select-pse':
await selectPse(ctx, options);
return { action: 'continue' };
case 'select-app': {
const aid = args[0];
if (!aid) {
ctx.error('Usage: select-app <aid>');
}
else {
await selectApp(ctx, aid, options);
}
return { action: 'continue' };
}
case 'read-record': {
const sfiArg = args[0];
const recordArg = args[1];
if (!sfiArg || !recordArg) {
ctx.error('Usage: read-record <sfi> <record>');
}
else {
const sfi = parseInt(sfiArg, 10);
const record = parseInt(recordArg, 10);
if (Number.isNaN(sfi) || Number.isNaN(record)) {
ctx.error('SFI and record must be numbers');
}
else {
await readRecord(ctx, sfi, record, options);
}
}
return { action: 'continue' };
}
case 'get-data': {
const tag = args[0];
if (!tag) {
ctx.error('Usage: get-data <tag>');
}
else {
await getData(ctx, tag, options);
}
return { action: 'continue' };
}
case 'list-apps':
await listApps(ctx, options);
return { action: 'continue' };
case 'info':
await cardInfo(ctx, options);
return { action: 'continue' };
default:
ctx.error(`Unknown command: ${String(cmd)}. Type 'help' for available commands.`);
return { action: 'continue' };
}
}
//# sourceMappingURL=commands.js.map