Skip to content

Commit 04594e3

Browse files
committed
feat(new tool): EMV TLV Parser
Fix CorentinTh#438
1 parent 46ba92b commit 04594e3

File tree

7 files changed

+400
-0
lines changed

7 files changed

+400
-0
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@
256256
"netstack.js": "^2.1.2",
257257
"nginx-config-formatter": "^1.4.5",
258258
"niceware": "^4.0.0",
259+
"node-emv": "^1.0.23",
259260
"node-forge": "^1.3.1",
260261
"openai-chat-tokens": "^0.2.8",
261262
"openpgp": "^6.2.0",

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<script setup lang="ts">
2+
import type { EmvTag } from 'node-emv';
3+
4+
defineProps<{
5+
tags: EmvTag[]
6+
title?: string
7+
}>();
8+
</script>
9+
10+
<template>
11+
<NCollapse :default-expanded-names="['root']">
12+
<NCollapseItem :title="title" name="root">
13+
<NTable :bordered="true" :single-line="false">
14+
<thead>
15+
<tr>
16+
<th>Tag</th>
17+
<th>Length</th>
18+
<th>Value</th>
19+
<th>Description</th>
20+
<th>Bit-Level Interpretation</th>
21+
</tr>
22+
</thead>
23+
<tbody>
24+
<template v-for="(tag, index) in tags" :key="index">
25+
<tr>
26+
<td>{{ tag.tag }}</td>
27+
<td>{{ tag.length }}</td>
28+
<td>{{ Array.isArray(tag.value) ? 'has nested tags' : tag.value }}</td>
29+
<td>{{ tag.description }}</td>
30+
<td>
31+
<ul v-if="tag.bitDetails?.length">
32+
<li v-for="(bitDesc, i) in tag.bitDetails" :key="i">
33+
{{ bitDesc }}
34+
</li>
35+
</ul>
36+
<span v-else>—</span>
37+
</td>
38+
</tr>
39+
<tr v-if="Array.isArray(tag.value)">
40+
<td colspan="5" style="padding-left: 2em;">
41+
<TLVTagTree :tags="tag.value" :title="`Nested in ${tag.tag}`" />
42+
</td>
43+
</tr>
44+
</template>
45+
</tbody>
46+
</NTable>
47+
</NCollapseItem>
48+
</NCollapse>
49+
</template>
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
/* eslint-disable max-statements-per-line */
2+
import emv, { type EmvBitDescription, type EmvTag } from 'node-emv';
3+
4+
/**
5+
* Utility functions
6+
*/
7+
function hexToBytes(hex: string): number[] {
8+
const clean = hex.replace(/\s+/g, '');
9+
if (clean.length % 2 !== 0) {
10+
throw new Error(`Invalid hex length: ${clean.length}`);
11+
}
12+
const out: number[] = [];
13+
for (let i = 0; i < clean.length; i += 2) {
14+
out.push(Number.parseInt(clean.slice(i, i + 2), 16));
15+
}
16+
return out;
17+
}
18+
19+
function hasBit(byte: number, bitIndex: number): boolean {
20+
return (byte & (1 << bitIndex)) !== 0;
21+
}
22+
23+
/**
24+
* Tag 9F10 – Issuer Application Data (IAD)
25+
*/
26+
export function parse9F10_IAD(hex: string): string[] {
27+
const bytes = hexToBytes(hex);
28+
const results: string[] = [];
29+
results.push(`IAD length: ${bytes.length} bytes`);
30+
results.push(`Raw IAD: ${hex}`);
31+
32+
// Heuristic scheme hint
33+
if (bytes.length === 22 || bytes.length === 24) { results.push('Likely Visa profile'); }
34+
else if (bytes.length === 32 || bytes.length === 34) { results.push('Likely Mastercard profile'); }
35+
else { results.push('Scheme unknown'); }
36+
37+
results.push(`First byte (ARC/IAD version): 0x${bytes[0].toString(16).padStart(2, '0')}`);
38+
39+
// Heuristic CVR extraction
40+
if (bytes.length >= 12) {
41+
const cvrBytes = bytes.slice(2, 6);
42+
results.push(`CVR (heuristic): ${cvrBytes.map(b => b.toString(16).padStart(2, '0')).join('')}`);
43+
}
44+
45+
return results;
46+
}
47+
48+
/**
49+
* Tag 9F33 – Terminal Capabilities
50+
*/
51+
export function parse9F33_TerminalCapabilities(hex: string): string[] {
52+
const bytes = hexToBytes(hex);
53+
if (bytes.length !== 3) { throw new Error('9F33 must be 3 bytes'); }
54+
const results: string[] = [];
55+
56+
if (hasBit(bytes[0], 7)) { results.push('Cash transaction supported'); }
57+
if (hasBit(bytes[0], 6)) { results.push('Goods purchase supported'); }
58+
if (hasBit(bytes[0], 5)) { results.push('Services purchase supported'); }
59+
if (hasBit(bytes[0], 4)) { results.push('Cashback supported'); }
60+
61+
if (hasBit(bytes[1], 7)) { results.push('Plaintext PIN for ICC supported'); }
62+
if (hasBit(bytes[1], 6)) { results.push('Enciphered PIN (online) supported'); }
63+
if (hasBit(bytes[1], 5)) { results.push('Signature supported'); }
64+
if (hasBit(bytes[1], 4)) { results.push('No CVM supported'); }
65+
66+
if (hasBit(bytes[2], 7)) { results.push('Terminal risk management supported'); }
67+
if (hasBit(bytes[2], 6)) { results.push('Issuer authentication supported'); }
68+
if (hasBit(bytes[2], 5)) { results.push('CDA supported'); }
69+
70+
return results;
71+
}
72+
73+
/**
74+
* Tag 9F34 – CVM Results
75+
*/
76+
export function parse9F34_CVMResults(hex: string): string[] {
77+
const bytes = hexToBytes(hex);
78+
if (bytes.length !== 3) { throw new Error('9F34 must be 3 bytes'); }
79+
const results: string[] = [];
80+
81+
results.push(`CVM Method Code: ${bytes[0]}`);
82+
results.push(`Condition Code: ${bytes[1]}`);
83+
84+
if (hasBit(bytes[2], 7)) { results.push('CVM performed'); }
85+
if (hasBit(bytes[2], 6)) { results.push('CVM failed'); }
86+
if (hasBit(bytes[2], 5)) { results.push('Bypass attested'); }
87+
88+
return results;
89+
}
90+
91+
/**
92+
* Tag 9F40 – Additional Terminal Capabilities
93+
*/
94+
export function parse9F40_AdditionalTerminalCapabilities(hex: string): string[] {
95+
const bytes = hexToBytes(hex);
96+
if (bytes.length < 4) { throw new Error('9F40 must be at least 4 bytes'); }
97+
const results: string[] = [];
98+
99+
if (hasBit(bytes[0], 7)) { results.push('Cash supported'); }
100+
if (hasBit(bytes[0], 6)) { results.push('Cashback supported'); }
101+
if (hasBit(bytes[0], 5)) { results.push('Purchase with cashback supported'); }
102+
if (hasBit(bytes[0], 4)) { results.push('Refund supported'); }
103+
104+
if (hasBit(bytes[1], 7)) { results.push('Offline plaintext PIN supported'); }
105+
if (hasBit(bytes[1], 6)) { results.push('Offline enciphered PIN supported'); }
106+
if (hasBit(bytes[1], 5)) { results.push('Online PIN supported'); }
107+
if (hasBit(bytes[1], 4)) { results.push('Signature supported'); }
108+
109+
if (hasBit(bytes[2], 7)) { results.push('SDA supported'); }
110+
if (hasBit(bytes[2], 6)) { results.push('DDA supported'); }
111+
if (hasBit(bytes[2], 5)) { results.push('CDA supported'); }
112+
if (hasBit(bytes[2], 4)) { results.push('Combined data authentication supported'); }
113+
114+
return results;
115+
}
116+
117+
/**
118+
* Tag 9F66 – TTQ (Terminal Transaction Qualifiers)
119+
*/
120+
export function parse9F66_TTQ(hex: string): string[] {
121+
const bytes = hexToBytes(hex);
122+
if (bytes.length < 4) { throw new Error('9F66 must be at least 4 bytes'); }
123+
const results: string[] = [];
124+
125+
if (hasBit(bytes[0], 7)) { results.push('Contactless EMV mode supported'); }
126+
if (hasBit(bytes[0], 6)) { results.push('Contactless MSD mode supported'); }
127+
if (hasBit(bytes[0], 5)) { results.push('Contact EMV supported'); }
128+
if (hasBit(bytes[0], 4)) { results.push('Offline data authentication supported'); }
129+
130+
if (hasBit(bytes[1], 7)) { results.push('No signature (QPS) supported'); }
131+
if (hasBit(bytes[1], 6)) { results.push('CVM required'); }
132+
if (hasBit(bytes[1], 5)) { results.push('Online PIN supported'); }
133+
if (hasBit(bytes[1], 4)) { results.push('Signature supported'); }
134+
135+
if (hasBit(bytes[2], 7)) { results.push('Online cryptogram required'); }
136+
if (hasBit(bytes[2], 6)) { results.push('Issuer update processing supported'); }
137+
138+
return results;
139+
}
140+
141+
/**
142+
* Tag 9F6C – CTQ (Card Transaction Qualifiers)
143+
*/
144+
export function parse9F6C_CTQ(hex: string): string[] {
145+
const bytes = hexToBytes(hex);
146+
if (bytes.length < 2) { throw new Error('9F6C must be at least 2 bytes'); }
147+
const results: string[] = [];
148+
149+
if (hasBit(bytes[0], 7)) { results.push('CVM required'); }
150+
if (hasBit(bytes[0], 6)) { results.push('Online PIN supported'); }
151+
if (hasBit(bytes[0], 5)) { results.push('Signature supported'); }
152+
if (hasBit(bytes[0], 4)) { results.push('No CVM supported'); }
153+
154+
if (hasBit(bytes[1], 7)) { results.push('Offline approval possible'); }
155+
if (hasBit(bytes[1], 6)) { results.push('Online processing possible'); }
156+
if (hasBit(bytes[1], 5)) { results.push('CVM performed by card'); }
157+
158+
return results;
159+
}
160+
161+
/**
162+
* Tag 9F6E – FFI (Form Factor Indicator)
163+
*/
164+
export function parse9F6E_FFI(hex: string): string[] {
165+
const bytes = hexToBytes(hex);
166+
if (bytes.length < 1) { throw new Error('9F6E must be at least 1 byte'); }
167+
const results: string[] = [];
168+
169+
const code = bytes[0];
170+
let description = 'Reserved/Other';
171+
if (code === 0x01) { description = 'Plastic card'; }
172+
else if (code === 0x02) { description = 'Mobile device'; }
173+
else if (code === 0x03) { description = 'Wearable'; }
174+
else if (code === 0x04) { description = 'Tokenized device'; }
175+
176+
results.push(`Form Factor Code: ${code} (${description})`);
177+
178+
if (bytes.length > 1) {
179+
results.push(`Extra bytes: ${bytes.slice(1).map(b => b.toString(16).padStart(2, '0')).join('')}`);
180+
}
181+
182+
return results;
183+
}
184+
185+
export function parseEmvData(tlvData: string, kernel: string) {
186+
let parsed: EmvTag[] = [];
187+
emv.describeKernel(tlvData, kernel, data => parsed = data);
188+
if (!parsed) {
189+
throw new Error('Invalid EMV TLV data');
190+
}
191+
192+
const describeItem = (item: EmvTag): EmvTag => {
193+
if (Array.isArray(item.value)) {
194+
return {
195+
tag: item.tag,
196+
length: item.length,
197+
description: item.description,
198+
value: item.value.map(sub_item => describeItem(sub_item)),
199+
};
200+
}
201+
202+
let bitDetails: string[] = [];
203+
204+
const getActiveBitsDescriptions = (bits: EmvBitDescription[][]) => bits.flat().filter(b => b.description !== 'RFU').map(b => `${b.description}: ${(b.value === '1' ? 'yes' : 'no')}`);
205+
206+
switch (item.tag) {
207+
case '95': // TVR
208+
emv.tvr(item.value, data => bitDetails = getActiveBitsDescriptions(data));
209+
break;
210+
case '9B': // TSI
211+
emv.tsi(item.value, data => bitDetails = getActiveBitsDescriptions(data));
212+
break;
213+
case '82': // AIP
214+
emv.aip(item.value, data => bitDetails = getActiveBitsDescriptions(data));
215+
break;
216+
case '8E': // CVM List
217+
emv.cvm(item.value, data => bitDetails = data);
218+
break;
219+
case '9F07': // AUC
220+
emv.auc(item.value, data => bitDetails = getActiveBitsDescriptions(data));
221+
break;
222+
case '9F10': // IAD
223+
bitDetails = parse9F10_IAD(item.value);
224+
break;
225+
case '9F33': // Terminal Capabilities
226+
bitDetails = parse9F33_TerminalCapabilities(item.value);
227+
break;
228+
case '9F34': // CVM Results
229+
bitDetails = parse9F34_CVMResults(item.value);
230+
break;
231+
case '9F40': // Additional Terminal Capabilities
232+
bitDetails = parse9F40_AdditionalTerminalCapabilities(item.value);
233+
break;
234+
case '9F66': // TTQ
235+
bitDetails = parse9F66_TTQ(item.value);
236+
break;
237+
case '9F6C': // CTQ
238+
bitDetails = parse9F6C_CTQ(item.value);
239+
break;
240+
case '9F6E': // FFI
241+
bitDetails = parse9F6E_FFI(item.value);
242+
break;
243+
}
244+
245+
return {
246+
tag: item.tag,
247+
length: item.length,
248+
description: item.description,
249+
value: item.value,
250+
bitDetails,
251+
};
252+
};
253+
return parsed.map(describeItem);
254+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<script setup lang="ts">
2+
import { parseEmvData } from './emv-tlv-decoder.service';
3+
import TLVTagTree from './TLVTagTree.vue';
4+
5+
const tlvInput = ref('4F07A00000000430605F2A02097882025C008407A0000000043060950500800080009A031508069C01009F02060000000001019F080200009F090200009F10120114000100000000000000E0DB2E438900FF');
6+
const parsedTags = ref<any[]>([]);
7+
const error = ref('');
8+
const kernel = ref('Generic');
9+
10+
function parseTlv() {
11+
try {
12+
const hex = tlvInput.value.replace(/\s+/g, '').toUpperCase();
13+
parsedTags.value = parseEmvData(hex, kernel.value);
14+
}
15+
catch (err: any) {
16+
error.value = err.toString();
17+
parsedTags.value = [];
18+
}
19+
}
20+
</script>
21+
22+
<template>
23+
<div>
24+
<NFormItem label="TLV Hex Input" mb-2>
25+
<NInput
26+
v-model:value="tlvInput"
27+
type="textarea"
28+
placeholder="Paste EMV TLV hex string (e.g. 6F1A8407A0000000031010A50F500B5649534120435245444954)"
29+
rows="4"
30+
/>
31+
</NFormItem>
32+
<c-select
33+
v-model:value="kernel"
34+
:options="['Generic', 'Visa', 'Mastercard', 'JCB', 'AMEX']"
35+
label="Kernel Type:"
36+
label-position="left"
37+
mb-2
38+
/>
39+
<div mb-2 flex justify-center>
40+
<NButton type="primary" @click="parseTlv">
41+
Parse TLV
42+
</NButton>
43+
</div>
44+
45+
<c-alert v-if="error">
46+
{{ error }}
47+
</c-alert>
48+
49+
<c-card v-if="parsedTags" title="Decoded EMV Tags">
50+
<TLVTagTree :tags="parsedTags" />
51+
</c-card>
52+
</div>
53+
</template>

0 commit comments

Comments
 (0)