diff --git a/frontend/index.html b/frontend/index.html index d0a62a1..80cf939 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -43,6 +43,7 @@ +
@@ -531,6 +532,14 @@
+
@@ -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 = '
Invalid hex input
'; + 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 — под списком карт.', diff --git a/frontend/tests/apdu_parse.test.js b/frontend/tests/apdu_parse.test.js new file mode 100644 index 0000000..6760908 --- /dev/null +++ b/frontend/tests/apdu_parse.test.js @@ -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')); +}); \ No newline at end of file