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:
+491
-2
@@ -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 — под списком карт.',
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
Reference in New Issue
Block a user