C-APDU Parser: client-side hex parser for APDU/expanded script decode

New 'Parser' subtab in C-APDU section. Detects format (compact C-APDU
chain, expanded AA/AE80 script) and renders collapsible tree with decoded
parameters. Key features:
- BER-TLV parser with multi-byte tag support and CR-bit handling
- Proactive command decoder (types from BER_QUAL, devices from BER_DEVICES)
- SIM/UICC toolkit parameter decoder (CA/EA/80)
- GSM7 text decode (7-bit packed, escape sequences)
- Best-effort UCS2/GSM7 text detection
- Compact C-APDU chain with implied-CLA support (SELECT + op chaining)
- 18 test cases from exchange vectors (apdu_parse.test.js)
This commit is contained in:
2026-08-20 22:11:55 +03:00
parent 8019326cfb
commit 72b0116bad
2 changed files with 667 additions and 2 deletions
+491 -2
View File
@@ -43,6 +43,7 @@
<button class="c-apdu-subtab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-sub="usim" onclick="cApduSwitchSubtab('usim')">USIM RFM</button>
<button class="c-apdu-subtab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-sub="ber" onclick="cApduSwitchSubtab('ber')">Expanded Script</button>
<button class="c-apdu-subtab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-sub="ram" onclick="cApduSwitchSubtab('ram')">RAM/GP</button>
<button class="c-apdu-subtab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-sub="parse" onclick="cApduSwitchSubtab('parse')" data-l10n="C-APDU Parser">C-APDU Parser</button>
</div>
<div id="c-apdu-sub-sim">
<div class="flex gap-4 mb-3">
@@ -531,6 +532,14 @@
<textarea id="ram-result" rows="3" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 bg-gray-100 dark:bg-slate-800"></textarea>
<button onclick="packToSp('ram-result')" id="ram-pack-btn" disabled class="mb-3 px-5 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Pack into Secured packet">Pack into Secured packet</button>
</div>
<div id="c-apdu-sub-parse" class="hidden">
<div class="mb-3">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="APDU hex">APDU hex</label>
<textarea id="parse-input" rows="4" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="80E60C00 ..."></textarea>
</div>
<button onclick="parseHex()" class="mb-3 px-5 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700" data-l10n="Parse">Parse</button>
<div id="parse-output"></div>
</div>
</div>
<div class="w-1/4">
<div class="border border-gray-300 dark:border-slate-600 rounded p-3 bg-gray-50 dark:bg-slate-800">
@@ -1025,7 +1034,7 @@ function cApduSwitchSubtab(name) {
btn.classList.toggle('text-gray-700', !active);
btn.classList.toggle('dark:text-slate-300', !active);
});
['sim', 'usim', 'ber', 'ram'].forEach(s => {
['sim', 'usim', 'ber', 'ram', 'parse'].forEach(s => {
const el = document.getElementById('c-apdu-sub-' + s);
if (el) el.classList.toggle('hidden', s !== name);
});
@@ -1033,7 +1042,7 @@ function cApduSwitchSubtab(name) {
updateSimUsimFields(name);
updateSimSelectMethod(name);
}
setHelpAnchor({sim:'sim-rfm', usim:'usim-rfm', ber:'ber-tlv', ram:'ram-gp'}[name] || 'c-apdu');
setHelpAnchor({sim:'sim-rfm', usim:'usim-rfm', ber:'ber-tlv', ram:'ram-gp', parse:'c-apdu'}[name] || 'c-apdu');
}
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
@@ -2457,6 +2466,485 @@ function genBerTlv() {
document.getElementById('ber-pack-btn').disabled = false;
}
// ===== C-APDU Parser =====
const PARSE_INS = {
'A4': {name:'SELECT', data:'lc'},
'B0': {name:'READ BINARY', data:'le'},
'B2': {name:'READ RECORD(S)', data:'le'},
'DC': {name:'UPDATE RECORD', data:'lc'},
'D6': {name:'UPDATE BINARY', data:'lc'},
'0E': {name:'ERASE BINARY', data:'none'},
'44': {name:'ACTIVATE FILE', data:'none'},
'04': {name:'DEACTIVATE FILE', data:'none'},
'20': {name:'VERIFY', data:'lc'},
'24': {name:'CHANGE', data:'lc'},
'E6': {name:'INSTALL', data:'lc'},
'E8': {name:'LOAD', data:'lc'},
'E4': {name:'DELETE', data:'lc'},
'F2': {name:'GET STATUS', data:'lc'},
'CA': {name:'GET DATA', data:'none'},
'E2': {name:'STORE DATA', data:'lc'},
'F0': {name:'SET STATUS', data:'lc'},
'82': {name:'EXTERNAL AUTHENTICATE', data:'lc'},
'88': {name:'INTERNAL AUTHENTICATE', data:'lc'},
};
function parseBerLen(hex, i) {
const b = parseInt(hex.substr(i, 2), 16);
if (b < 128) return {len: b, consumed: 2};
const n = parseInt(hex.substr(i + 2, b * 2), 16);
return {len: n, consumed: 2 + b * 2};
}
function parseTlvList(hex) {
const out = [];
let i = 0;
while (i + 4 <= hex.length) {
const tag = hex.substr(i, 2);
let tagBytes = 2;
if ((parseInt(tag, 16) & 0x1F) === 0x1F) {
tagBytes = 4;
if (i + 4 > hex.length) break;
}
const r = parseBerLen(hex, i + tagBytes);
const len = r.len;
const consumed = tagBytes + r.consumed;
if (i + consumed + len * 2 > hex.length) break;
const value = hex.substr(i + consumed, len * 2);
const raw = hex.substr(i, consumed + len * 2);
out.push({tag, length: len, value, raw});
i += consumed + len * 2;
}
return out;
}
function baseCompTag(tag) {
const b = parseInt(tag, 16);
return (b >= 0x80 && b <= 0x9F) ? (b & 0x7F).toString(16).padStart(2, '0').toUpperCase() : tag;
}
function decodePrivileges(hex) {
if (hex.length < 2) return '';
const b1 = parseInt(hex.substr(0, 2), 16);
const parts = [];
if (b1 & 0x80) parts.push('Security Domain');
if (b1 & 0x40) parts.push('DAP Verification');
if (b1 & 0x20) parts.push('Delegated Management');
if (b1 & 0x10) parts.push('Card Lock');
if (b1 & 0x08) parts.push('Card Reset');
if (b1 & 0x04) parts.push('Global Registry');
if (b1 & 0x02) parts.push('CVM Management');
if (b1 & 0x01) parts.push('Mandated DAP Verification');
if (hex.length >= 4) {
const b2 = parseInt(hex.substr(2, 2), 16);
if (b2 & 0x80) parts.push('Global Lock');
if (b2 & 0x04) parts.push('Final Application');
if (b2 & 0x02) parts.push('Receipt Generation');
}
if (hex.length >= 6) {
const b3 = parseInt(hex.substr(4, 2), 16);
if (b3 & 0x80) parts.push('Contactless Activation');
}
return parts.join(', ') || 'None';
}
function gsm7Decode(octets) {
const bits = [];
for (const b of octets) {
for (let j = 0; j < 8; j++) bits.push((b >> j) & 1);
}
let out = '';
let escape = false;
for (let i = 0; i + 7 <= bits.length; i += 7) {
let septet = 0;
for (let j = 0; j < 7; j++) septet |= bits[i + j] << j;
if (escape) {
const ext = Object.entries(GSM7_EXT_MAP).find(([,v]) => v === septet);
out += ext ? ext[0] : '?';
escape = false;
} else if (septet === 0x1B) {
escape = true;
} else {
out += septet < GSM7_ALPHABET.length ? GSM7_ALPHABET[septet] : '?';
}
}
return out;
}
function decodeTextData(hex) {
if (!hex || hex.length < 2) return '?';
if (hex.length >= 4 && hex.length % 4 === 0) {
let allPrintable = true;
let ucs2 = '';
for (let i = 0; i < hex.length; i += 4) {
const cp = parseInt(hex.substr(i, 4), 16);
if (cp < 0x20 || cp > 0x10FFFF) { allPrintable = false; break; }
ucs2 += String.fromCodePoint(cp);
}
if (allPrintable && ucs2.length > 0) return ucs2;
}
try {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
const gsm = gsm7Decode(bytes);
if (gsm.length > 0 && !gsm.includes('\uFFFD')) return gsm;
} catch (e) {}
return '?';
}
function describeToolkit(hex) {
const children = [];
if (!hex || hex.length < 2) return children;
let isUicc = true;
if (hex.length >= 2) {
const first = parseInt(hex.substr(0, 2), 16);
if (first <= 0x0F) isUicc = false;
}
let i = 0;
if (isUicc) {
children.push({label:'Priority', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
children.push({label:'Timers', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
children.push({label:'Text length', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
const menus = parseInt(hex.substr(i,2), 16); i += 2;
children.push({label:'Menu entries', hex:hex.substr(i-2,2), desc:menus.toString()});
for (let m = 0; m < menus && i + 4 <= hex.length; m++) {
children.push({label:'Menu pair '+(m+1), hex:hex.substr(i,4), desc:'Pos ' + parseInt(hex.substr(i,2),16) + ', ID ' + hex.substr(i+2,2)});
i += 4;
}
children.push({label:'Channels', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
if (i + 2 <= hex.length) {
const mslLen = parseInt(hex.substr(i,2), 16); i += 2;
if (mslLen > 0 && i + mslLen * 2 <= hex.length) {
children.push({label:'MSL', hex:hex.substr(i-2, 2+mslLen*2), desc:'Length ' + mslLen + ' value ' + hex.substr(i, mslLen*2)});
i += mslLen * 2;
} else {
children.push({label:'MSL', hex:hex.substr(i-2,2), desc:'No MSL'});
}
}
if (i + 2 <= hex.length) {
const tarLen = parseInt(hex.substr(i,2), 16); i += 2;
if (tarLen > 0 && i + tarLen * 2 <= hex.length) {
children.push({label:'TAR', hex:hex.substr(i-2, 2+tarLen*2), desc:hex.substr(i, tarLen*2)});
i += tarLen * 2;
} else {
children.push({label:'TAR', hex:hex.substr(i-2,2), desc:'No TAR'});
}
}
if (i + 2 <= hex.length) {
children.push({label:'Max services', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()});
}
} else {
const adLen = parseInt(hex.substr(i,2), 16); i += 2;
if (adLen > 0 && i + adLen * 2 <= hex.length) {
children.push({label:'Access Domain', hex:hex.substr(i-2, 2+adLen*2), desc:hex.substr(i, adLen*2)});
i += adLen * 2;
} else {
children.push({label:'Access Domain', hex:hex.substr(i-2,2), desc:'Empty'});
}
children.push({label:'Priority', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
children.push({label:'Timers', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
children.push({label:'Text length', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
const menus = parseInt(hex.substr(i,2), 16); i += 2;
children.push({label:'Menu entries', hex:hex.substr(i-2,2), desc:menus.toString()});
for (let m = 0; m < menus && i + 4 <= hex.length; m++) {
children.push({label:'Menu pair '+(m+1), hex:hex.substr(i,4), desc:'Pos ' + parseInt(hex.substr(i,2),16) + ', ID ' + hex.substr(i+2,2)});
i += 4;
}
children.push({label:'Channels', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
if (i + 2 <= hex.length) {
const mslLen = parseInt(hex.substr(i,2), 16); i += 2;
if (mslLen > 0 && i + mslLen * 2 <= hex.length) {
children.push({label:'MSL', hex:hex.substr(i-2, 2+mslLen*2), desc:'Length ' + mslLen + ' value ' + hex.substr(i, mslLen*2)});
i += mslLen * 2;
} else {
children.push({label:'MSL', hex:hex.substr(i-2,2), desc:'No MSL'});
}
}
if (i + 2 <= hex.length) {
const tarLen = parseInt(hex.substr(i,2), 16); i += 2;
if (tarLen > 0 && i + tarLen * 2 <= hex.length) {
children.push({label:'TAR', hex:hex.substr(i-2, 2+tarLen*2), desc:hex.substr(i, tarLen*2)});
i += tarLen * 2;
} else {
children.push({label:'TAR', hex:hex.substr(i-2,2), desc:'No TAR'});
}
}
}
return children;
}
function describeProactiveCommand(tlvs) {
const children = [];
for (const tlv of tlvs) {
const base = baseCompTag(tlv.tag);
let desc = '';
if (base === '01') {
const cmdNum = parseInt(tlv.value.substr(0, 2), 16);
const qual = tlv.value.substr(4, 2);
let typeName = 'Command #' + cmdNum;
for (const [k, arr] of Object.entries(BER_QUAL)) {
const found = arr.find(([v]) => v === qual);
if (found) { typeName += ', ' + k.toUpperCase() + ' qual ' + found[1]; break; }
}
desc = typeName;
} else if (base === '02') {
const src = tlv.value.substr(0, 2);
const dst = tlv.value.substr(2, 2);
const srcName = (BER_DEVICES.find(([v]) => v === src) || [])[1] || src;
const dstName = (BER_DEVICES.find(([v]) => v === dst) || [])[1] || dst;
desc = 'from ' + srcName + ' to ' + dstName;
} else if (base === '0D' || base === '8D') {
desc = decodeTextData(tlv.value);
} else if (base === '04' || base === '84') {
if (tlv.value.length >= 4) {
const unit = ['minutes','seconds','tenths'][parseInt(tlv.value.substr(0,2), 16)] || '?';
const val = parseInt(tlv.value.substr(2,2), 16);
desc = val + ' ' + unit;
}
} else if (base === '05' || base === '85') {
desc = 'Alpha ID: ' + tlv.value;
} else if (base === '0E' || base === '8E') {
const tone = tlv.value.substr(0, 2);
const toneName = (BER_TONES.find(([v]) => v === tone) || [])[1] || tone;
desc = 'Tone: ' + toneName;
} else if (base === '12' || base === '92') {
desc = 'File list: ' + tlv.value;
} else {
desc = tlv.value || '(empty)';
}
children.push({label:'TLV ' + tlv.tag + ' (' + baseCompTag(tlv.tag) + ')', hex:tlv.raw, desc});
}
return children;
}
function parseBerScript(hex) {
const rows = [];
let i = 0;
if (hex.startsWith('AA')) {
const r = parseBerLen(hex, 2);
i = 2 + r.consumed;
} else if (hex.startsWith('AE80')) {
i = 4;
} else {
return rows;
}
while (i < hex.length) {
if (hex.startsWith('0000', i)) break;
if (i + 4 > hex.length) break;
const tag = hex.substr(i, 2);
const r = parseBerLen(hex, i + 2);
const len = r.len;
const consumed = 2 + r.consumed;
if (i + consumed + len * 2 > hex.length) break;
const value = hex.substr(i + consumed, len * 2);
const raw = hex.substr(i, consumed + len * 2);
let label = 'Row ' + (rows.length + 1);
let children = [];
if (tag === '22') {
label = 'C-APDU';
children = [{label:'APDU', hex:value, desc:value}];
} else if (tag === '81') {
label = 'Immediate Action';
children = parseActionRow(value, '81');
} else if (tag === '82') {
label = 'Error Action';
children = parseActionRow(value, '82');
} else if (tag === '83') {
label = 'Script Chaining';
children = [{label:'Flags', hex:value.substr(0,2), desc:parseChainingFlags(value.substr(0,2))}];
if (value.length > 2) children.push({label:'Script ID', hex:value.substr(2), desc:value.substr(2)});
} else {
label = 'Row ' + (rows.length + 1) + ' (tag ' + tag + ')';
}
rows.push({label, hex:raw, desc:'', children});
i += consumed + len * 2;
}
if (i < hex.length && !hex.startsWith('0000', i)) {
rows.push({label:'Trailing (unrecognized)', hex:hex.substr(i), desc:hex.substr(i)});
}
return rows;
}
function parseChainingFlags(hex) {
if (hex === '01') return 'First';
if (hex === '11') return 'First, keep across reset';
if (hex === '02') return 'Intermediary (more to follow)';
if (hex === '03') return 'Intermediary (last)';
return hex;
}
function parseActionRow(hex, tag) {
if (!hex || hex === '00') return [{label:'No action', hex:hex||'', desc:tag === '82' ? 'No action (82 00)' : ''}];
if (hex.length === 2) return [{label:'Action indicator', hex, desc:'Value ' + hex}];
const tlvs = parseTlvList(hex);
if (tlvs.length > 0) {
const children = describeProactiveCommand(tlvs);
return [{label:'Proactive command', hex, desc:'', children}];
}
return [{label:'Raw', hex, desc:hex}];
}
function parseOneApdu(hex) {
if (hex.length < 10) return null;
const cla = hex.substr(0, 2);
const ins = hex.substr(2, 2);
const p1 = hex.substr(4, 2);
const p2 = hex.substr(6, 2);
const p3 = hex.substr(8, 2);
const insInfo = PARSE_INS[ins];
const children = [
{label:'CLA', hex:cla, desc:cla === '80' ? 'GP/RAM' : cla === 'A0' ? 'SIM' : 'UICC'},
{label:'INS', hex:ins, desc:insInfo ? insInfo.name : 'Unknown'},
{label:'P1', hex:p1, desc:''},
{label:'P2', hex:p2, desc:''},
];
if (insInfo && insInfo.data === 'le') {
children.push({label:'Le', hex:p3, desc:'Expected length: ' + parseInt(p3, 16)});
} else if (insInfo && insInfo.data === 'none') {
children.push({label:'P3', hex:p3, desc:'No data'});
} else {
const lc = parseInt(p3, 16);
children.push({label:'Lc', hex:p3, desc:'Data length: ' + lc});
if (lc > 0 && hex.length >= 10 + lc * 2) {
const data = hex.substr(10, lc * 2);
children.push({label:'Data', hex:data, desc:data});
if (hex.length > 10 + lc * 2) {
const le = hex.substr(10 + lc * 2, 2);
children.push({label:'Le', hex:le, desc:'Expected response length: ' + parseInt(le, 16)});
}
}
}
const totalLen = 10 + (children.find(c => c.label === 'Data') ? parseInt(children.find(c => c.label === 'Lc').hex, 16) * 2 : 0) + (children.find(c => c.label === 'Le (tail)') ? 2 : 0);
return {label:'APDU', hex:hex.substr(0, totalLen), desc:'', children};
}
function parseCompactApdus(hex) {
const apdus = [];
let i = 0;
let prevCla = '';
while (i + 4 <= hex.length) {
const first = hex.substr(i, 2);
const second = hex.substr(i + 2, 2);
let fullCla = '';
let fullIns = '';
let consumed = 0;
if ((first === '80' || first === 'A0' || first === '00') && PARSE_INS[second]) {
fullCla = first;
fullIns = second;
consumed = 2;
} else if (PARSE_INS[first] && prevCla && i > 0) {
fullCla = prevCla;
fullIns = first;
consumed = 0;
}
if (fullCla) {
const p3 = parseInt(hex.substr(i + 6 + consumed, 2), 16);
const dataType = PARSE_INS[fullIns].data;
let apduLen = 8;
if (dataType === 'lc') {
apduLen += p3 * 2;
}
if (i + consumed + apduLen <= hex.length) {
const apduHex = fullCla + hex.substr(i + consumed, apduLen);
const parsed = parseOneApdu(apduHex);
if (parsed) {
prevCla = fullCla;
parsed.hex = hex.substr(i, consumed + apduLen);
parsed.label = consumed === 0 ? 'APDU (implied CLA)' : 'APDU';
apdus.push(parsed);
i += consumed + apduLen;
continue;
}
}
}
apdus.push({label:'Unknown', hex:hex.substr(i, 2), desc:'Unrecognized'});
i += 2;
}
if (i < hex.length) {
apdus.push({label:'Trailing (unrecognized)', hex:hex.substr(i), desc:hex.substr(i)});
}
return apdus;
}
function parseHexTree(hex) {
const s = hex.replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (s.length % 2 !== 0) return {label:'Error', hex:'', desc:'Odd hex length'};
if (s.startsWith('AA') && s.length >= 4) {
const rows = parseBerScript(s);
return {label:'Expanded Script (AA)', hex:s, desc:'', children:rows};
}
if (s.startsWith('AE80')) {
const rows = parseBerScript(s);
return {label:'Expanded Script (AE80)', hex:s, desc:'', children:rows};
}
const apdus = parseCompactApdus(s);
return {label:'Compact C-APDU chain', hex:s, desc:'', children:apdus};
}
function renderTree(node, container) {
const div = document.createElement('div');
div.className = 'ml-3 border-l-2 border-gray-300 dark:border-slate-600 pl-2 mb-1';
const header = document.createElement('div');
header.className = 'flex gap-2 items-start py-0.5 cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-700 rounded';
if (node.children && node.children.length > 0) {
const toggle = document.createElement('span');
toggle.textContent = '▾';
toggle.className = 'text-xs text-gray-400 w-4 flex-shrink-0';
header.appendChild(toggle);
header.onclick = () => {
const body = div.querySelector('.tree-body');
if (body) {
body.classList.toggle('hidden');
toggle.textContent = body.classList.contains('hidden') ? '▸' : '▾';
}
};
} else {
const dot = document.createElement('span');
dot.textContent = '·';
dot.className = 'text-xs text-gray-300 w-4 flex-shrink-0';
header.appendChild(dot);
}
const label = document.createElement('span');
label.className = 'text-xs font-medium text-gray-700 dark:text-slate-300';
label.textContent = node.label;
header.appendChild(label);
if (node.hex) {
const hex = document.createElement('span');
hex.className = 'text-xs font-mono text-gray-500 dark:text-slate-400 ml-2';
hex.textContent = node.hex;
header.appendChild(hex);
}
if (node.desc) {
const desc = document.createElement('span');
desc.className = 'text-xs text-gray-600 dark:text-slate-400 ml-2';
desc.textContent = node.desc;
header.appendChild(desc);
}
div.appendChild(header);
if (node.children && node.children.length > 0) {
const body = document.createElement('div');
body.className = 'tree-body';
div.appendChild(body);
for (const child of node.children) {
renderTree(child, body);
}
}
container.appendChild(div);
}
function parseHex() {
const input = document.getElementById('parse-input');
const output = document.getElementById('parse-output');
const hex = input.value.replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (!hex || hex.length % 2 !== 0) {
output.innerHTML = '<div class="text-xs text-red-500">Invalid hex input</div>';
return;
}
const tree = parseHexTree(hex);
output.innerHTML = '';
renderTree(tree, output);
}
// ===== Response Parser =====
const SW_MAP = {
generic: {
@@ -4933,6 +5421,7 @@ const LANG_RU = {
'Error: ciphering requires KIc key': 'Ошибка: ciphering requires KIc key',
'Error: MAC requires KID key': 'Ошибка: MAC requires KID key',
'Response parser': 'Парсер ответов',
'C-APDU Parser': 'Разбор C-APDU',
'Cards': 'Карты',
'Card reader': 'Картридер',
'Card presets are stored locally in your browser. JSON export/import buttons are below the card list.': 'Данные карт хранятся локально в браузере. Кнопки экспорта/импорта JSON — под списком карт.',
+176
View File
@@ -0,0 +1,176 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
function findNode(tree, label) {
if (tree.label === label) return tree;
if (tree.children) {
for (const c of tree.children) {
const r = findNode(c, label);
if (r) return r;
}
}
return null;
}
function findNodes(tree, label) {
const out = [];
if (tree.label === label) out.push(tree);
if (tree.children) {
for (const c of tree.children) out.push(...findNodes(c, label));
}
return out;
}
const consts = ['BER_QUAL', 'BER_DEVICES', 'BER_TONES', 'GSM7_ALPHABET', 'GSM7_EXT_MAP'];
let prefix = '';
for (const c of consts) {
const m = html.match(new RegExp('const\\s+' + c + '\\s*=\\s*(?:\\[|\\{)[\\s\\S]*?(?:\\n[\\]\\}];|[\\]\\}];\\n)'));
if (!m) throw new Error(c + ' not found');
prefix += m[0].replace(/^const /, 'var ') + '\n';
}
const start = html.indexOf('// ===== C-APDU Parser =====');
const end = html.indexOf('// ===== Response Parser =====');
eval(prefix + html.slice(start, end).replace(/^const PARSE_INS = /m, 'var PARSE_INS = '));
test('Compact RAM INSTALL [for install] UICC', () => {
const tree = parseHexTree('80E60C00214F08A000000151000000C70100EA128010000000020101020200011603B0000100');
assert.strictEqual(tree.label, 'Compact C-APDU chain');
assert.strictEqual(tree.children.length, 1);
const apdu = tree.children[0];
assert.strictEqual(apdu.label, 'APDU');
assert.ok(findNode(apdu, 'INS').desc.includes('INSTALL'));
});
test('Compact RAM INSTALL [for install] SIM (CA) access domain 5A', () => {
const tree = parseHexTree('80E60C00204F08A000000151000000C70100CA11015A000000020101020200011603B00001');
const apdu = tree.children[0];
assert.ok(findNode(apdu, 'INS').desc.includes('INSTALL'));
});
test('Expanded AA DISPLAY TEXT UCS2 "HI" + 5s', () => {
const tree = parseHexTree('AA1681130103012101020281820D040048004904020105');
assert.strictEqual(tree.label, 'Expanded Script (AA)');
assert.strictEqual(tree.children.length, 1);
assert.strictEqual(tree.children[0].label, 'Immediate Action');
});
test('Expanded AA DISPLAY TEXT GSM7 "HI"', () => {
const tree = parseHexTree('AA0F810D0103012101020281820D02C824');
assert.strictEqual(tree.children.length, 1);
assert.strictEqual(tree.children[0].label, 'Immediate Action');
});
test('Expanded AA REFRESH + file list', () => {
const tree = parseHexTree('AA10810E01030101000202818212036F3B2F');
assert.strictEqual(tree.children.length, 1);
assert.strictEqual(tree.children[0].label, 'Immediate Action');
});
test('Expanded AE80 PLAY TONE + Error Action no action', () => {
const tree = parseHexTree('AE80810C0103200101020281820E010182000000');
assert.strictEqual(tree.label, 'Expanded Script (AE80)');
assert.strictEqual(tree.children.length, 2);
assert.strictEqual(tree.children[0].label, 'Immediate Action');
assert.strictEqual(tree.children[1].label, 'Error Action');
});
test('Immediate Action CR-set tags (real-world encoding)', () => {
const tree = parseHexTree('AA0E8101818109810301010482028182');
assert.strictEqual(tree.children.length, 2);
assert.strictEqual(tree.children[0].label, 'Immediate Action');
assert.strictEqual(tree.children[1].label, 'Immediate Action');
});
test('CR-set DISPLAY TEXT UCS2 "HI" + 5s', () => {
const tree = parseHexTree('AA1581138103012101820281828D040048004984020105');
assert.strictEqual(tree.children.length, 1);
});
test('CR-set PLAY TONE (tag 8E)', () => {
const tree = parseHexTree('AA0E810C8103012001820281828E0101');
assert.strictEqual(tree.children.length, 1);
});
test('CR-set REFRESH file list (tag 92)', () => {
const tree = parseHexTree('AA0F810D81030101048202818292023F00');
assert.strictEqual(tree.children.length, 1);
});
test('Expanded AA Error Action = proactive DISPLAY TEXT', () => {
const tree = parseHexTree('AA0F820D0103012101020281820D022852');
assert.strictEqual(tree.children.length, 1);
assert.strictEqual(tree.children[0].label, 'Error Action');
});
test('Expanded AA 22 C-APDU row (GET STATUS)', () => {
const tree = parseHexTree('AA09220780F22000024F00');
assert.strictEqual(tree.children.length, 1);
assert.strictEqual(tree.children[0].label, 'C-APDU');
});
test('Chained SIM SELECT + UPDATE RECORD', () => {
const tree = parseHexTree('A0A40000026F3BDC0102032B2F2D');
assert.strictEqual(tree.label, 'Compact C-APDU chain');
assert.strictEqual(tree.children.length, 2);
assert.strictEqual(tree.children[0].label, 'APDU');
});
test('INTERNAL AUTHENTICATE', () => {
const tree = parseHexTree('8088000008800F3495BA2355CD');
assert.strictEqual(tree.children.length, 1);
const apdu = tree.children[0];
assert.strictEqual(apdu.label, 'APDU');
assert.ok(findNode(apdu, 'INS').desc.includes('INTERNAL AUTHENTICATE'));
assert.ok(findNode(apdu, 'Data'));
});
test('baseCompTag clears CR bit for 0x80-0x9F', () => {
assert.strictEqual(baseCompTag('81'), '01');
assert.strictEqual(baseCompTag('82'), '02');
assert.strictEqual(baseCompTag('8D'), '0D');
assert.strictEqual(baseCompTag('8E'), '0E');
assert.strictEqual(baseCompTag('92'), '12');
assert.strictEqual(baseCompTag('84'), '04');
assert.strictEqual(baseCompTag('85'), '05');
assert.strictEqual(baseCompTag('01'), '01');
assert.strictEqual(baseCompTag('22'), '22');
});
test('parseTlvList handles BER-TLVs', () => {
const tlvs = parseTlvList('810301210182028182');
assert.strictEqual(tlvs.length, 2);
assert.strictEqual(tlvs[0].tag, '81');
assert.strictEqual(tlvs[0].length, 3);
assert.strictEqual(tlvs[0].value, '012101');
assert.strictEqual(tlvs[1].tag, '82');
});
test('gsm7Decode unpacks "HI" from C824', () => {
const bytes = new Uint8Array([0xC8, 0x24]);
assert.strictEqual(gsm7Decode(bytes), 'HI');
});
test('decodePrivileges', () => {
assert.ok(decodePrivileges('00').includes('None'));
assert.ok(decodePrivileges('80').includes('Security Domain'));
});