a36f26e8e1
- pysimSendOta: Response Parser filled strictly from decoded PoR (por.decoded) for both SPI2 variants; envelope SW/failures no longer leak into it — they render in a new inline result line (#sp-send-result) next to Send to Card, always including SW on failure - Default-level stderr tracing per send (no flags needed): 'OTA SEND: SPI .. KIc .. KID .. TAR .. CNTR .. LEN ..B CHUNKS N', 'OTA SEND FAILED: chunk N SW xxxx' and 'OTA PoR[envelope|sms-submit]: status=.. TAR=.. CNTR=.. PCNTR=.. RPL=.. RHL=..' (+ compact summary / undecodable raw / none fallbacks) - _decode_por: surface every parsed PoR field verbatim (cntr, rpl, rhl, cc_rc, raw) instead of status/tar/pcntr only - genSp: CNTR normalization padEnd -> padStart so short input like '1' becomes 0000000001, not 1000000000 (counter is big-endian 5 bytes) - tests: TestDecodePor completeness + cntr_low field-report cases Version 1.9.3 -> 1.9.4 everywhere; SW cache otaman-v14 -> otaman-v15
6102 lines
284 KiB
HTML
6102 lines
284 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>OTAMan</title>
|
||
<link rel="stylesheet" href="style.css">
|
||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||
<link rel="icon" type="image/png" href="sim.png" sizes="462x462">
|
||
<link rel="manifest" href="manifest.json">
|
||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||
<link rel="apple-touch-icon" href="icon-192.png">
|
||
<script src="des-bundle.js"></script>
|
||
<script src="aes-bundle.js"></script>
|
||
</head>
|
||
<body class="bg-neutral-50 dark:bg-slate-900 text-gray-800 dark:text-slate-200">
|
||
|
||
<div class="max-w-7xl mx-auto px-6 py-2">
|
||
|
||
<div class="flex items-center justify-between mb-3">
|
||
<h1 class="text-2xl font-bold text-heading">OTAMan <span id="slogan" class="text-sm font-normal text-gray-500 dark:text-slate-400 ml-2" data-l10n="SIM OTA with a Human Face">SIM OTA with a Human Face</span> <span class="text-xs text-gray-400 dark:text-slate-500 ml-1">v1.9.4</span></h1>
|
||
<div class="flex items-center gap-4">
|
||
<button id="install-btn" class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700" style="display:none">INSTALL PWA [for offline use]</button>
|
||
<a href="https://github.com/anttro/otaman" target="_blank" class="text-xs text-gray-400 hover:text-gray-600 dark:text-slate-500 dark:hover:text-slate-300">github</a>
|
||
<a id="help-link" href="help.html" target="_blank" rel="noopener" class="text-xs text-gray-400 hover:text-gray-600 dark:text-slate-500 dark:hover:text-slate-300" data-l10n="help">help</a>
|
||
<button onclick="toggleLang()" id="lang-btn" class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700">EN</button>
|
||
<button onclick="toggleTheme()" id="theme-btn" class="px-2 py-1 text-sm rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700">🌙</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex gap-1 mb-4 border-b border-gray-300 dark:border-slate-600">
|
||
<button class="tab-btn active px-4 py-1.5 text-sm rounded-t bg-blue-600 dark:bg-blue-500 text-white dark:text-white" data-tab="c-apdu">C-APDU</button>
|
||
<button class="tab-btn px-4 py-1.5 text-sm rounded-t bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-tab="sp">Secured Packet</button>
|
||
<button class="tab-btn px-4 py-1.5 text-sm rounded-t bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-tab="response" data-l10n="Response parser">Response parser</button>
|
||
<button class="tab-btn px-4 py-1.5 text-sm rounded-t bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-tab="cards" data-l10n="Cards">Cards</button>
|
||
<button class="tab-btn px-4 py-1.5 text-sm rounded-t bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-tab="pysim" data-l10n="Card reader">Card reader</button>
|
||
</div>
|
||
|
||
<div id="tab-c-apdu" class="tab-content">
|
||
<div class="flex gap-4">
|
||
<div class="flex-1">
|
||
<div class="flex justify-center gap-2 mb-3">
|
||
<button class="c-apdu-subtab px-4 py-1.5 text-sm rounded-full bg-blue-600 text-white" data-sub="sim" onclick="cApduSwitchSubtab('sim')">SIM 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="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">
|
||
<div class="w-full">
|
||
<div class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Command">Command</label>
|
||
<select id="sim-cmd" onchange="updateSimUsimFields('sim')" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="select">SELECT</option>
|
||
<option value="update-record">UPDATE RECORD</option>
|
||
<option value="update-binary">UPDATE BINARY</option>
|
||
<option value="read-record">READ RECORD</option>
|
||
<option value="read-binary">READ BINARY</option>
|
||
<option value="erase-binary">ERASE BINARY</option>
|
||
<option value="activate-file">ACTIVATE FILE</option>
|
||
<option value="deactivate-file">DEACTIVATE FILE</option>
|
||
<option value="verify">VERIFY PIN</option>
|
||
<option value="change">CHANGE PIN</option>
|
||
<option value="disable">DISABLE PIN</option>
|
||
<option value="enable">ENABLE PIN</option>
|
||
<option value="unblock">UNBLOCK PIN</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div id="sim-select-section">
|
||
<div id="sim-select-checkbox-row" class="mb-3">
|
||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-slate-300">
|
||
<input id="sim-select" type="checkbox" checked class="rounded border-gray-300 dark:border-slate-600 dark:bg-slate-800" onchange="updateSimSelectSection('sim')">
|
||
Start with SELECT
|
||
</label>
|
||
</div>
|
||
<div id="sim-select-method-body">
|
||
<div id="sim-sel-method-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="SELECT method">SELECT method</label>
|
||
<select id="sim-sel-method" onchange="updateSimSelectMethod('sim'); updateP1P2Display('sim')" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="fid">By FID</option>
|
||
<option value="path">By full path from MF</option>
|
||
<option value="dfname">By DF name / AID</option>
|
||
<option value="chain">ADF RFM chain</option>
|
||
</select>
|
||
</div>
|
||
<div id="sim-sel-fid-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">FID (2 bytes)</label>
|
||
<input id="sim-sel-fid" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="6FC5" maxlength="4">
|
||
</div>
|
||
<div id="sim-sel-path-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Path (hex)">Path (hex)</label>
|
||
<input id="sim-sel-path" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="7FFF6FC5">
|
||
</div>
|
||
<div id="sim-sel-dfname-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">AID (hex)</label>
|
||
<input id="sim-sel-dfname" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="A000000151000000">
|
||
</div>
|
||
<div id="sim-sel-chain-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="FID chain (comma-separated)">FID chain (comma-separated)</label>
|
||
<input id="sim-sel-chain" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="3F00,6F3A">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="sim-record-row" class="mb-3">
|
||
<div class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Record #">Record #</label>
|
||
<input id="sim-record" type="number" min="1" max="255" value="1" onchange="updateP1P2Display('sim')" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Selection mode (P2)">Selection mode (P2)</label>
|
||
<select id="sim-rec-mode" onchange="updateP1P2Display('sim')" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="04">Absolute (04)</option>
|
||
<option value="02">Next (02)</option>
|
||
<option value="03">Previous (03)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="sim-offset-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Offset (hex)">Offset (hex)</label>
|
||
<input id="sim-offset" onchange="updateP1P2Display('sim')" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="0000" maxlength="4" value="0000">
|
||
</div>
|
||
|
||
<div id="sim-act-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Target">Target</label>
|
||
<select id="sim-act-target" onchange="updateActFileInput('sim')" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="current" data-l10n="Current EF (case 1)">Current EF (case 1)</option>
|
||
<option value="fid" data-l10n="By FID">By FID</option>
|
||
<option value="mfpath" data-l10n="Path from MF">Path from MF</option>
|
||
<option value="dfpath" data-l10n="Path from current DF">Path from current DF</option>
|
||
</select>
|
||
<input id="sim-act-file" class="mt-1.5 font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="6FC5 / 7FFF6FC5" style="display:none">
|
||
</div>
|
||
|
||
<div id="sim-le-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">Le (hex)</label>
|
||
<input id="sim-le" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="00" maxlength="2" value="00">
|
||
</div>
|
||
|
||
<div id="sim-data-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Data (hex)">Data (hex)</label>
|
||
<textarea id="sim-data" rows="3" 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"></textarea>
|
||
</div>
|
||
|
||
<div id="sim-recsize-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Record size (bytes)">Record size (bytes)</label>
|
||
<input id="sim-recsize" type="number" min="1" value="0" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
|
||
<div id="sim-pin-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">PIN</label>
|
||
<select id="sim-pin" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="01">PIN1 (01)</option>
|
||
<option value="02">PIN2 (02)</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div id="sim-pin-val-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">PIN value</label>
|
||
<input id="sim-pin-val" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="1234" maxlength="8">
|
||
</div>
|
||
|
||
<div id="sim-pin-old-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">Old PIN</label>
|
||
<input id="sim-pin-old" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="1234" maxlength="8">
|
||
</div>
|
||
|
||
<div id="sim-pin-new-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">New PIN</label>
|
||
<input id="sim-pin-new" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="5678" maxlength="8">
|
||
</div>
|
||
|
||
<div id="sim-override-row" class="mb-3">
|
||
<div class="flex gap-2 mb-2">
|
||
<div class="flex-1">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">P1 (hex)</label>
|
||
<input id="sim-p1" disabled class="font-mono disabled:opacity-50 disabled:cursor-not-allowed border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="00" maxlength="2" value="00">
|
||
</div>
|
||
<div class="flex-1">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">P2 (hex)</label>
|
||
<input id="sim-p2" disabled class="font-mono disabled:opacity-50 disabled:cursor-not-allowed border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="00" maxlength="2" value="00">
|
||
</div>
|
||
</div>
|
||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-slate-300">
|
||
<input id="sim-override" type="checkbox" class="rounded border-gray-300 dark:border-slate-600 dark:bg-slate-800" onchange="toggleSimOverride('sim')">
|
||
Allow P1/P2 editing
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button onclick="genSimUsim('sim')" class="mb-3 px-5 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700" data-l10n="Generate APDU">Generate APDU</button>
|
||
<textarea id="sim-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('sim-result')" id="sim-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-usim" class="hidden">
|
||
<div class="flex gap-4 mb-3">
|
||
<div class="w-full">
|
||
<div class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Command">Command</label>
|
||
<select id="usim-cmd" onchange="updateSimUsimFields('usim')" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="select">SELECT</option>
|
||
<option value="update-record">UPDATE RECORD</option>
|
||
<option value="update-binary">UPDATE BINARY</option>
|
||
<option value="read-record">READ RECORD</option>
|
||
<option value="read-binary">READ BINARY</option>
|
||
<option value="activate-file">ACTIVATE FILE</option>
|
||
<option value="deactivate-file">DEACTIVATE FILE</option>
|
||
<option value="verify">VERIFY PIN</option>
|
||
<option value="change">CHANGE PIN</option>
|
||
<option value="disable">DISABLE PIN</option>
|
||
<option value="enable">ENABLE PIN</option>
|
||
<option value="unblock">UNBLOCK PIN</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div id="usim-select-section">
|
||
<div id="usim-select-checkbox-row" class="mb-3">
|
||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-slate-300">
|
||
<input id="usim-select" type="checkbox" checked class="rounded border-gray-300 dark:border-slate-600 dark:bg-slate-800" onchange="updateSimSelectSection('usim')">
|
||
Start with SELECT
|
||
</label>
|
||
</div>
|
||
<div id="usim-select-method-body">
|
||
<div id="usim-sel-method-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="SELECT method">SELECT method</label>
|
||
<select id="usim-sel-method" onchange="updateSimSelectMethod('usim'); updateP1P2Display('usim')" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="fid">By FID</option>
|
||
<option value="path">By full path from MF</option>
|
||
<option value="dfname">By DF name / AID</option>
|
||
<option value="chain">ADF RFM chain</option>
|
||
</select>
|
||
</div>
|
||
<div id="usim-sel-fid-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">FID (2 bytes)</label>
|
||
<input id="usim-sel-fid" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="6FC5" maxlength="4">
|
||
</div>
|
||
<div id="usim-sel-path-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Path (hex)">Path (hex)</label>
|
||
<input id="usim-sel-path" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="7FFF6FC5">
|
||
</div>
|
||
<div id="usim-sel-dfname-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">AID (hex)</label>
|
||
<input id="usim-sel-dfname" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="A000000151000000">
|
||
</div>
|
||
<div id="usim-sel-chain-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="FID chain (comma-separated)">FID chain (comma-separated)</label>
|
||
<input id="usim-sel-chain" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="3F00,6F3A">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="usim-record-row" class="mb-3">
|
||
<div class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Record #">Record #</label>
|
||
<input id="usim-record" type="number" min="1" max="255" value="1" onchange="updateP1P2Display('usim')" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Selection mode (P2)">Selection mode (P2)</label>
|
||
<select id="usim-rec-mode" onchange="updateP1P2Display('usim')" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="04">Absolute (04)</option>
|
||
<option value="02">Next (02)</option>
|
||
<option value="03">Previous (03)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="usim-offset-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Offset (hex)">Offset (hex)</label>
|
||
<input id="usim-offset" onchange="updateP1P2Display('usim')" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="0000" maxlength="4" value="0000">
|
||
</div>
|
||
|
||
<div id="usim-act-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Target">Target</label>
|
||
<select id="usim-act-target" onchange="updateActFileInput('usim')" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="current" data-l10n="Current EF (case 1)">Current EF (case 1)</option>
|
||
<option value="fid" data-l10n="By FID">By FID</option>
|
||
<option value="mfpath" data-l10n="Path from MF">Path from MF</option>
|
||
<option value="dfpath" data-l10n="Path from current DF">Path from current DF</option>
|
||
</select>
|
||
<input id="usim-act-file" class="mt-1.5 font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="6FC5 / 7FFF6FC5" style="display:none">
|
||
</div>
|
||
|
||
<div id="usim-le-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">Le (hex)</label>
|
||
<input id="usim-le" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="00" maxlength="2" value="00">
|
||
</div>
|
||
|
||
<div id="usim-data-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Data (hex)">Data (hex)</label>
|
||
<textarea id="usim-data" rows="3" 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"></textarea>
|
||
</div>
|
||
|
||
<div id="usim-recsize-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Record size (bytes)">Record size (bytes)</label>
|
||
<input id="usim-recsize" type="number" min="1" value="0" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
|
||
<div id="usim-pin-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">PIN</label>
|
||
<select id="usim-pin" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="01">PIN1 (01)</option>
|
||
<option value="02">PIN2 (02)</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div id="usim-pin-val-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">PIN value</label>
|
||
<input id="usim-pin-val" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="1234" maxlength="8">
|
||
</div>
|
||
|
||
<div id="usim-pin-old-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">Old PIN</label>
|
||
<input id="usim-pin-old" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="1234" maxlength="8">
|
||
</div>
|
||
|
||
<div id="usim-pin-new-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">New PIN</label>
|
||
<input id="usim-pin-new" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="5678" maxlength="8">
|
||
</div>
|
||
|
||
<div id="usim-override-row" class="mb-3">
|
||
<div class="flex gap-2 mb-2">
|
||
<div class="flex-1">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">P1 (hex)</label>
|
||
<input id="usim-p1" disabled class="font-mono disabled:opacity-50 disabled:cursor-not-allowed border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="00" maxlength="2" value="00">
|
||
</div>
|
||
<div class="flex-1">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">P2 (hex)</label>
|
||
<input id="usim-p2" disabled class="font-mono disabled:opacity-50 disabled:cursor-not-allowed border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="00" maxlength="2" value="00">
|
||
</div>
|
||
</div>
|
||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-slate-300">
|
||
<input id="usim-override" type="checkbox" class="rounded border-gray-300 dark:border-slate-600 dark:bg-slate-800" onchange="toggleSimOverride('usim')">
|
||
Allow P1/P2 editing
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button onclick="genSimUsim('usim')" class="mb-3 px-5 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700" data-l10n="Generate APDU">Generate APDU</button>
|
||
<textarea id="usim-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('usim-result')" id="usim-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-ber" class="hidden">
|
||
<div class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">Format</label>
|
||
<select id="ber-format" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 dark:text-slate-300 font-mono">
|
||
<option value="AA">Definite (AA)</option>
|
||
<option value="AE">Indefinite (AE)</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="mb-3">
|
||
<div class="flex flex-wrap gap-1 mb-2 items-center">
|
||
<span class="text-sm font-medium text-gray-700 dark:text-slate-300 mr-1">Commands</span>
|
||
<button onclick="addBerRow('c-apdu')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ C-APDU</button>
|
||
<button onclick="addBerRow('immediate')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ Immediate</button>
|
||
<button onclick="addBerRow('error')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ Error</button>
|
||
<button onclick="addBerRow('chaining')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ Chaining</button>
|
||
</div>
|
||
<div id="ber-rows" class="space-y-2"></div>
|
||
</div>
|
||
|
||
<button onclick="genBerTlv()" class="mb-3 px-5 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700" data-l10n="Generate Expanded Script APDU">Generate Expanded Script APDU</button>
|
||
<textarea id="ber-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 dark:text-slate-300"></textarea>
|
||
<button onclick="packToSp('ber-result')" id="ber-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-ram" class="hidden">
|
||
<div class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Command">Command</label>
|
||
<select id="ram-cmd" onchange="updateRamFields()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="install-load">INSTALL [for load]</option>
|
||
<option value="install-install">INSTALL [for install]</option>
|
||
<option value="install-make-sel">INSTALL [for make selectable]</option>
|
||
<option value="install-reg-update">INSTALL [for registry update]</option>
|
||
<option value="install-extradition">INSTALL [for extradition]</option>
|
||
<option value="load">LOAD</option>
|
||
<option value="delete">DELETE</option>
|
||
<option value="get-status">GET STATUS</option>
|
||
<option value="get-data">GET DATA</option>
|
||
<option value="store-data">STORE DATA</option>
|
||
<option value="set-status">SET STATUS</option>
|
||
<option value="ext-auth">EXTERNAL AUTHENTICATE</option>
|
||
<option value="int-auth">INTERNAL AUTHENTICATE</option>
|
||
</select>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">AID (hex)</label>
|
||
<input id="ram-aid" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="A000000151000000">
|
||
</div>
|
||
<div id="ram-sdaid-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">SD AID (hex)</label>
|
||
<input id="ram-sdaid" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="A000000151000000">
|
||
</div>
|
||
<div id="ram-elfaid-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">ELF AID (hex)</label>
|
||
<input id="ram-elfaid" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="A000000151000000">
|
||
</div>
|
||
<div id="ram-modaid-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">Module AID (hex)</label>
|
||
<input id="ram-modaid" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="A000000151000000">
|
||
</div>
|
||
<div id="ram-priv-row" class="mb-3">
|
||
<div class="flex items-center gap-3 mb-2">
|
||
<label class="text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Privileges">Privileges</label>
|
||
<input id="ram-priv" readonly class="font-mono w-20 border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 bg-gray-100 dark:bg-slate-800" value="00">
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||
<div class="font-medium text-gray-700 dark:text-slate-300 mb-1" data-l10n="Byte 1">Byte 1</div>
|
||
<div class="font-medium text-gray-700 dark:text-slate-300 mb-1" data-l10n="Byte 2">Byte 2</div>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="ram-priv-b1" value="80" onchange="updateRamPriv()"> Security Domain</label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="ram-priv-b2" value="80" onchange="updateRamPriv()"> Trusted Path</label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="ram-priv-b1" value="40" onchange="updateRamPriv()"> DAP Verification</label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="ram-priv-b2" value="40" onchange="updateRamPriv()"> Authorized Management</label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="ram-priv-b1" value="20" onchange="updateRamPriv()"> Delegated Management</label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="ram-priv-b2" value="20" onchange="updateRamPriv()"> Token Verification</label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="ram-priv-b1" value="10" onchange="updateRamPriv()"> Card Lock</label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="ram-priv-b2" value="10" onchange="updateRamPriv()"> Global Delete</label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="ram-priv-b1" value="08" onchange="updateRamPriv()"> Card Terminate</label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="ram-priv-b2" value="08" onchange="updateRamPriv()"> Global Lock</label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="ram-priv-b1" value="04" onchange="updateRamPriv()"> Card Reset</label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="ram-priv-b2" value="04" onchange="updateRamPriv()"> Global Registry</label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="ram-priv-b1" value="02" onchange="updateRamPriv()"> CVM Management</label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="ram-priv-b1" value="01" onchange="updateRamPriv()"> Mandated DAP Verification</label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="ram-priv-b2" value="02" onchange="updateRamPriv()"> Final Application</label>
|
||
<div></div>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="ram-priv-b3" value="80" onchange="updateRamPriv()"> Receipt Generation</label>
|
||
</div>
|
||
</div>
|
||
<div id="ram-toolkit-row" class="mb-3">
|
||
<div class="mb-2">
|
||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-slate-300">
|
||
<input id="ram-toolkit-enable" type="checkbox" class="rounded border-gray-300 dark:border-slate-600 dark:bg-slate-800" onchange="updateRamToolkit()">
|
||
SIM/UICC Toolkit parameters
|
||
</label>
|
||
</div>
|
||
<div id="ram-toolkit-body" class="">
|
||
<div class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Toolkit mode">Toolkit mode</label>
|
||
<select id="ram-tk-mode" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="ca">SIM Toolkit (Tag CA)</option>
|
||
<option value="ea">UICC Toolkit (Tag EA)</option>
|
||
</select>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-x-3 gap-y-2 text-sm">
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Priority">Priority</label>
|
||
<input id="ram-tk-priority" type="number" min="0" max="255" value="0" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Timers (max)">Timers (max)</label>
|
||
<input id="ram-tk-timers" type="number" min="0" max="255" value="0" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Menu text length">Menu text length</label>
|
||
<input id="ram-tk-textlen" type="number" min="0" max="255" value="0" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Menu items">Menu items</label>
|
||
<input id="ram-tk-menus" type="number" min="0" max="255" value="0" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="First menu position">First menu position</label>
|
||
<input id="ram-tk-firstpos" type="number" min="0" max="255" value="0" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="First menu ID (hex)">First menu ID (hex)</label>
|
||
<input id="ram-tk-firstid" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800" placeholder="00" maxlength="2" value="00">
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Last menu position">Last menu position</label>
|
||
<input id="ram-tk-lastpos" type="number" min="0" max="255" value="0" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Last menu ID (hex)">Last menu ID (hex)</label>
|
||
<input id="ram-tk-lastid" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800" placeholder="00" maxlength="2" value="00">
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Channels (max)">Channels (max)</label>
|
||
<input id="ram-tk-channels" type="number" min="0" max="255" value="0" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300">MSL (SPI1, hex)</label>
|
||
<select id="ram-tk-msl" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800">
|
||
<option value="00">00 — no verification</option>
|
||
<option value="11">11 — RC/CC/DS</option>
|
||
<option value="12">12 — RC/DS/CC</option>
|
||
<option value="15">15 — RC/DS/CC + MAC</option>
|
||
<option value="16" selected>16 — RC/DS/CC + MAC + Cipher</option>
|
||
<option value="19">19 — RC/DS/CC + MAC + Cipher + DS</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300">TAR (3 bytes)</label>
|
||
<input id="ram-tk-tar" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800" placeholder="B00001" maxlength="6" value="B00001">
|
||
</div>
|
||
<div id="ram-tk-ad-row">
|
||
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Access domain (hex)">Access domain (hex)</label>
|
||
<input id="ram-tk-ad" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800" placeholder="00" maxlength="2" value="00">
|
||
</div>
|
||
<div id="ram-tk-services-row">
|
||
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Max services">Max services</label>
|
||
<input id="ram-tk-services" type="number" min="0" max="255" value="0" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="ram-load-data-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Load data (hex)">Load data (hex)</label>
|
||
<textarea id="ram-load-data" rows="3" 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"></textarea>
|
||
</div>
|
||
<div id="ram-data-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Data (hex)">Data (hex)</label>
|
||
<textarea id="ram-data" rows="3" 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"></textarea>
|
||
</div>
|
||
<div id="ram-block-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Block number">Block number</label>
|
||
<input id="ram-block" type="number" min="0" value="0" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
<div id="ram-enc-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Encryption">Encryption</label>
|
||
<select id="ram-enc" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="00">No encryption, more blocks</option>
|
||
<option value="40">App-dependent encryption, more blocks</option>
|
||
<option value="80">No encryption, last block</option>
|
||
<option value="C0">App-dependent encryption, last block</option>
|
||
<option value="E0">With encryption, last block</option>
|
||
</select>
|
||
</div>
|
||
<div id="ram-authp1-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">Security level (P1)</label>
|
||
<select id="ram-authp1" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="00">00 — No secure messaging</option>
|
||
<option value="01">01 — C-MAC</option>
|
||
<option value="03">03 — C-DEC + C-MAC</option>
|
||
</select>
|
||
</div>
|
||
<div id="ram-del-mode-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="DELETE mode">DELETE mode</label>
|
||
<select id="ram-del-mode" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="00">AID only (00)</option>
|
||
<option value="80">AID and all related objects (80)</option>
|
||
</select>
|
||
</div>
|
||
<div id="ram-gs-mode-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="GET STATUS mode">GET STATUS mode</label>
|
||
<select id="ram-gs-mode" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="80">ISD (80)</option>
|
||
<option value="40">Applications and SSD (40)</option>
|
||
<option value="20">Executable Load Files (20)</option>
|
||
<option value="10">ELF and modules (10)</option>
|
||
</select>
|
||
</div>
|
||
<div id="ram-gs-p2-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="GET STATUS format">GET STATUS format</label>
|
||
<select id="ram-gs-p2" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="02">02 — First/all, TLV format</option>
|
||
<option value="03">03 — Next, TLV format</option>
|
||
<option value="00">00 — First/all, raw format (deprecated)</option>
|
||
</select>
|
||
</div>
|
||
<div id="ram-tag-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="GET DATA tag">GET DATA tag</label>
|
||
<select id="ram-gd-tag-sel" onchange="document.getElementById('ram-tag').value=this.value" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 mb-1.5">
|
||
<option value="" data-l10n="— select preset —">— select preset —</option>
|
||
<option value="42">42 — Issuer Identification Number (IIN)</option>
|
||
<option value="45">45 — Card Image Number (CIN)</option>
|
||
<option value="66">66 — Card Data / SD Management Data</option>
|
||
<option value="67">67 — Card Capability Information</option>
|
||
<option value="E0">E0 — Key Information Template</option>
|
||
<option value="D3">D3 — Current Security Level</option>
|
||
<option value="2F00">2F00 — List of Applications</option>
|
||
<option value="FF21">FF21 — Extended Card Resources Info</option>
|
||
<option value="5F50">5F50 — SD Manager URL</option>
|
||
<option value="C1">C1 — Sequence Counter (SCP02/03)</option>
|
||
<option value="C2">C2 — Confirmation Counter</option>
|
||
<option value="7F21">7F21 — Certificate (SD public key)</option>
|
||
<option value="5031">5031 — Certificate info (EF.OD)</option>
|
||
</select>
|
||
<input id="ram-tag" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="5F50" maxlength="4" value="5F50">
|
||
</div>
|
||
<div id="ram-ss-mode-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="SET STATUS type">SET STATUS type</label>
|
||
<select id="ram-ss-mode" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="80">ISD (80)</option>
|
||
<option value="40">Application or SSD (40)</option>
|
||
<option value="60">SD + associated apps (60)</option>
|
||
</select>
|
||
</div>
|
||
<div id="ram-ss-state-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="State">State</label>
|
||
<select id="ram-ss-state" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="00">00 — Unlock (previous state)</option>
|
||
<option value="80">80 — Lock (LOCKED)</option>
|
||
<option value="01">01 — OP_READY</option>
|
||
<option value="07">07 — INITIALIZED</option>
|
||
<option value="0F">0F — SECURED</option>
|
||
<option value="7F">7F — CARD_LOCKED</option>
|
||
<option value="FF">FF — TERMINATED</option>
|
||
</select>
|
||
</div>
|
||
<button onclick="genRam()" class="mb-3 px-5 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700" data-l10n="Generate APDU">Generate APDU</button>
|
||
<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="80 E6 0C 00 ..."></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="Decode">Decode</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">
|
||
<select id="conv-type" onchange="updateConvFields()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 mb-2">
|
||
<option value="nibswap">Nibble swap</option>
|
||
<option value="imsi">IMSI → EF.IMSI</option>
|
||
<option value="msisdn">MSISDN → BCD</option>
|
||
<option value="iccid">ICCID → hex</option>
|
||
<option value="spn">Provider Name → SPN</option>
|
||
<option value="plmn">MCC,MNC → PLMNsel</option>
|
||
<option value="plmnwact">MCC,MNC,AcT → PLMNwAcT</option>
|
||
</select>
|
||
<div class="flex flex-col gap-2 mb-2">
|
||
<input id="conv-input" class="font-mono flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
|
||
<button onclick="genConvert()" class="px-3 py-1.5 bg-blue-600 text-white text-xs font-medium rounded hover:bg-blue-700" data-l10n="Convert">Convert</button>
|
||
</div>
|
||
<div id="conv-plmn-fields" class="" style="display:none">
|
||
<div class="flex gap-2 mb-2">
|
||
<div class="flex-1">
|
||
<input id="conv-mcc" maxlength="3" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="MCC">
|
||
</div>
|
||
<div class="flex-1">
|
||
<input id="conv-mnc" maxlength="3" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="MNC">
|
||
</div>
|
||
</div>
|
||
<div id="conv-act-row" class="" style="display:none">
|
||
<select id="conv-act" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
|
||
<option value="8000">GSM (8000)</option>
|
||
<option value="4000">UTRAN (4000)</option>
|
||
<option value="2000">E-UTRAN (2000)</option>
|
||
<option value="1000">NGRAN (1000)</option>
|
||
<option value="C000">GSM + UTRAN (C000)</option>
|
||
<option value="6000">UTRAN + E-UTRAN (6000)</option>
|
||
<option value="E000">GSM + UTRAN + E-UTRAN (E000)</option>
|
||
<option value="F000">All (F000)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<textarea id="conv-result" rows="3" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 mt-2 bg-white dark:bg-slate-800"></textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="tab-sp" class="tab-content hidden">
|
||
<div class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">SPI1 (Security Parameter Indicator)</label>
|
||
<div class="flex gap-2 items-center">
|
||
<select id="sp-spi1" onchange="updateSp()" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="00">00 — No security</option>
|
||
<option value="01">01 — RC only</option>
|
||
<option value="02">02 — CC/MAC only</option>
|
||
<option value="06" selected>06 — CC/MAC + Ciphering</option>
|
||
<option value="0A">0A — CC/MAC + Counter (available)</option>
|
||
<option value="0E">0E — CC/MAC + Ciphering + Counter (available)</option>
|
||
<option value="12">12 — CC/MAC + Counter (higher)</option>
|
||
<option value="16">16 — CC/MAC + Ciphering + Counter (higher)</option>
|
||
<option value="1A">1A — CC/MAC + Counter (+1)</option>
|
||
<option value="1E">1E — CC/MAC + Ciphering + Counter (+1)</option>
|
||
</select>
|
||
<input id="sp-spi1-hex" readonly class="w-16 font-mono border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-2 bg-gray-100 dark:bg-slate-800 text-center" value="06">
|
||
</div>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">SPI2 (PoR settings)</label>
|
||
<div class="flex gap-2 items-center">
|
||
<select id="sp-spi2" onchange="updateSp()" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="00" selected>00 — No PoR, no security</option>
|
||
<option value="01">01 — PoR required, no security</option>
|
||
<option value="05">05 — PoR required, RC</option>
|
||
<option value="09">09 — PoR required, CC</option>
|
||
<option value="0D">0D — PoR required, DS</option>
|
||
<option value="11">11 — PoR required, no security, ciphered</option>
|
||
<option value="02">02 — PoR on error, no security</option>
|
||
<option value="06">06 — PoR on error, RC</option>
|
||
</select>
|
||
<select id="sp-spi2-sm" onchange="updateSp()" class="border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="0">DELIVER-REPORT</option>
|
||
<option value="1">SUBMIT SM</option>
|
||
</select>
|
||
<input id="sp-spi2-hex" readonly class="w-16 font-mono border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-2 bg-gray-100 dark:bg-slate-800 text-center" value="00">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">KIc (Ciphering Key Identifier)</label>
|
||
<div class="flex gap-2 items-center">
|
||
<select id="sp-kic-idx" onchange="updateSpKic()" class="border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="0">Implicit (0)</option>
|
||
<option value="1" selected>1</option>
|
||
<option value="2">2</option>
|
||
<option value="3">3</option>
|
||
<option value="4">4</option>
|
||
<option value="5">5</option>
|
||
<option value="6">6</option>
|
||
<option value="7">7</option>
|
||
<option value="8">8</option>
|
||
<option value="9">9</option>
|
||
<option value="A">A</option>
|
||
<option value="B">B</option>
|
||
<option value="C">C</option>
|
||
<option value="D">D</option>
|
||
<option value="E">E</option>
|
||
<option value="F">F</option>
|
||
</select>
|
||
<select id="sp-kic-alg" onchange="updateSpKic()" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="01">01 — DES-CBC (8B key)</option>
|
||
<option value="02">02 — AES-CBC (16/24/32B key)</option>
|
||
<option value="05" selected>05 — 3DES 2-key outer-CBC (16B key)</option>
|
||
<option value="09">09 — 3DES 3-key outer-CBC (24B key)</option>
|
||
<option value="0D">0D — DES-ECB (8B key)</option>
|
||
</select>
|
||
<input id="sp-kic-hex" readonly class="w-12 font-mono border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-2 bg-gray-100 dark:bg-slate-800 text-center" value="15">
|
||
</div>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">KID (MAC Key Identifier)</label>
|
||
<div class="flex gap-2 items-center">
|
||
<select id="sp-kid-idx" onchange="updateSpKid()" class="border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="0">Implicit (0)</option>
|
||
<option value="1" selected>1</option>
|
||
<option value="2">2</option>
|
||
<option value="3">3</option>
|
||
<option value="4">4</option>
|
||
<option value="5">5</option>
|
||
<option value="6">6</option>
|
||
<option value="7">7</option>
|
||
<option value="8">8</option>
|
||
<option value="9">9</option>
|
||
<option value="A">A</option>
|
||
<option value="B">B</option>
|
||
<option value="C">C</option>
|
||
<option value="D">D</option>
|
||
<option value="E">E</option>
|
||
<option value="F">F</option>
|
||
</select>
|
||
<select id="sp-kid-alg" onchange="updateSpKid()" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="01">01 — DES-CBC (8B key)</option>
|
||
<option value="02">02 — AES-CMAC (16/24/32B key)</option>
|
||
<option value="05" selected>05 — 3DES 2-key outer-CBC (16B key)</option>
|
||
<option value="09">09 — 3DES 3-key outer-CBC (24B key)</option>
|
||
</select>
|
||
<input id="sp-kid-hex" readonly class="w-12 font-mono border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-2 bg-gray-100 dark:bg-slate-800 text-center" value="15">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex gap-2 mb-3">
|
||
<div class="flex-1">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">TAR (3 bytes)</label>
|
||
<input id="sp-tar" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="B00001" maxlength="6" value="B00001">
|
||
</div>
|
||
<div class="flex-1">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Card presets">Card presets</label>
|
||
<select id="sp-card-sel" onchange="cardsApply(this.value)" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="" data-l10n="— Select card —">— Select card —</option>
|
||
</select>
|
||
</div>
|
||
<div class="flex-1">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">Counter (5 bytes)</label>
|
||
<div class="flex gap-1 items-center">
|
||
<button type="button" onclick="spCntrAdjust(-1)" class="px-2.5 py-2 border border-gray-300 dark:border-slate-600 text-sm rounded hover:bg-gray-200 dark:bg-slate-700 dark:hover:bg-slate-600">−</button>
|
||
<input id="sp-cntr" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 flex-1 dark:bg-slate-800" placeholder="0000000001" maxlength="10" value="0000000001">
|
||
<button type="button" onclick="spCntrAdjust(1)" class="px-2.5 py-2 border border-gray-300 dark:border-slate-600 text-sm rounded hover:bg-gray-200 dark:bg-slate-700 dark:hover:bg-slate-600">+</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex gap-2 mb-3">
|
||
<div class="flex-1">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">KIc Key (hex)</label>
|
||
<textarea id="sp-kic-key" rows="2" 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="16 bytes key"></textarea>
|
||
</div>
|
||
<div class="flex-1">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">KID Key (hex)</label>
|
||
<textarea id="sp-kid-key" rows="2" 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="16 bytes key"></textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">Padding byte</label>
|
||
<select id="sp-padding" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="00" selected>00 (TS 102 225 default)</option>
|
||
<option value="FF">FF</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">APDU (hex)</label>
|
||
<textarea id="sp-apdu" rows="3" 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"></textarea>
|
||
</div>
|
||
|
||
<button onclick="genSp()" class="mb-3 px-5 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700" data-l10n="Generate Secure Packet">Generate Secure Packet</button>
|
||
<button onclick="pysimVerifySp()" id="sp-verify-btn" class="hidden mb-3 px-5 py-2.5 bg-slate-600 text-white text-sm font-medium rounded hover:bg-slate-700" data-l10n="Verify vs pySim">Verify vs pySim</button>
|
||
<button onclick="pysimSendOta()" id="sp-send-btn" class="hidden mb-3 px-5 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700" data-l10n="Send to Card">Send to Card</button>
|
||
<textarea id="sp-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>
|
||
<div id="sp-verify-result" class="mt-2 text-xs font-mono hidden"></div>
|
||
<div id="sp-send-result" class="mt-2 text-xs font-mono hidden"></div>
|
||
</div>
|
||
|
||
<div id="tab-response" class="tab-content hidden">
|
||
<div class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Command">Command</label>
|
||
<select id="resp-cmd" onchange="updateRespCmd()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="" data-l10n="— Select command —">— Select command —</option>
|
||
<optgroup label="SIM / USIM">
|
||
<option value="sim-select">SELECT</option>
|
||
<option value="sim-status">STATUS</option>
|
||
<option value="sim-read-binary">READ BINARY</option>
|
||
<option value="sim-update-binary">UPDATE BINARY</option>
|
||
<option value="sim-read-record">READ RECORD</option>
|
||
<option value="sim-update-record">UPDATE RECORD</option>
|
||
<option value="sim-verify">VERIFY PIN</option>
|
||
<option value="sim-change">CHANGE PIN</option>
|
||
<option value="sim-disable">DISABLE PIN</option>
|
||
<option value="sim-enable">ENABLE PIN</option>
|
||
<option value="sim-unblock">UNBLOCK PIN</option>
|
||
<option value="sim-deactivate">DEACTIVATE FILE</option>
|
||
<option value="sim-activate">ACTIVATE FILE</option>
|
||
<option value="sim-challenge">GET CHALLENGE</option>
|
||
<option value="sim-auth">AUTHENTICATE</option>
|
||
<option value="sim-profile">TERMINAL PROFILE</option>
|
||
<option value="sim-envelope">ENVELOPE</option>
|
||
<option value="sim-fetch">FETCH</option>
|
||
<option value="sim-terminal-rsp">TERMINAL RESPONSE</option>
|
||
<option value="sim-manage-channel">MANAGE CHANNEL</option>
|
||
<option value="sim-msc">MANAGE SECURE CHANNEL</option>
|
||
<option value="sim-retrieve">RETRIEVE DATA</option>
|
||
<option value="sim-set-data">SET DATA</option>
|
||
<option value="sim-suspend">SUSPEND UICC</option>
|
||
</optgroup>
|
||
<optgroup label="RAM (GlobalPlatform)">
|
||
<option value="ram-install-load">INSTALL [for load]</option>
|
||
<option value="ram-install-install">INSTALL [for install]</option>
|
||
<option value="ram-install-make-sel">INSTALL [for make selectable]</option>
|
||
<option value="ram-install-reg-update">INSTALL [for registry update]</option>
|
||
<option value="ram-install-extradition">INSTALL [for extradition]</option>
|
||
<option value="ram-load">LOAD</option>
|
||
<option value="ram-delete">DELETE</option>
|
||
<option value="ram-get-status">GET STATUS</option>
|
||
<option value="ram-get-data">GET DATA</option>
|
||
<option value="ram-store-data">STORE DATA</option>
|
||
<option value="ram-set-status">SET STATUS</option>
|
||
<option value="ram-ext-auth">EXTERNAL AUTHENTICATE</option>
|
||
<option value="ram-int-auth">INTERNAL AUTHENTICATE</option>
|
||
<option value="ram-select">SELECT</option>
|
||
<option value="ram-init-update">INITIALIZE UPDATE</option>
|
||
<option value="ram-put-key">PUT KEY</option>
|
||
<option value="ram-manage-channel">MANAGE CHANNEL</option>
|
||
</optgroup>
|
||
</select>
|
||
</div>
|
||
<div class="flex gap-3 mb-3">
|
||
<div class="w-24">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">SW</label>
|
||
<input id="resp-sw" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" maxlength="4" placeholder="9000">
|
||
</div>
|
||
<div class="flex-1">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">Context</label>
|
||
<input id="resp-ctx" readonly class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 bg-gray-100 dark:bg-slate-800" value="generic">
|
||
</div>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Response data (hex)">Response data (hex)</label>
|
||
<textarea id="resp-data" rows="3" 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="E3 1A 4F 08 A0 00 00 00 03 00 00 00 9F 70 01 07 C5 03 80 00 00"></textarea>
|
||
</div>
|
||
<button onclick="decodeResponse()" class="mb-3 px-5 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700" data-l10n="Decode">Decode</button>
|
||
<div id="resp-output" 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 whitespace-pre-wrap" style="min-height:100px"></div>
|
||
</div>
|
||
|
||
<div id="tab-cards" class="tab-content hidden">
|
||
<p class="text-xs text-gray-500 dark:text-slate-400 mb-3" data-l10n="Card presets are stored locally in your browser. JSON export/import buttons are below the card list.">Card presets are stored locally in your browser. JSON export/import buttons are below the card list.</p>
|
||
<div class="flex gap-2 mb-3">
|
||
<input id="cards-name" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="Name (e.g. Foobar SIM)">
|
||
<input id="cards-iccid" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 font-mono" placeholder="ICCID (optional)">
|
||
<button onclick="cardsAdd()" class="px-4 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 whitespace-nowrap" data-l10n="Add">Add</button>
|
||
</div>
|
||
<div class="flex gap-2 mb-3 flex-wrap">
|
||
<input id="cards-kic" class="w-12 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono text-center" placeholder="KIc" maxlength="2">
|
||
<input id="cards-kid" class="w-12 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono text-center" placeholder="KID" maxlength="2">
|
||
<input id="cards-spi1" class="w-12 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono text-center" placeholder="SPI1" maxlength="2">
|
||
<input id="cards-spi2" class="w-12 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono text-center" placeholder="SPI2" maxlength="2">
|
||
<input id="cards-tar" class="w-20 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono" placeholder="ISD TAR" maxlength="6">
|
||
<input id="cards-cntr" class="w-24 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono" placeholder="Counter" maxlength="10">
|
||
</div>
|
||
<div class="flex gap-2 mb-3">
|
||
<input id="cards-kic-key" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 font-mono" placeholder="KIc key (32 hex chars)">
|
||
<input id="cards-kid-key" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 font-mono" placeholder="KID key (32 hex chars)">
|
||
</div>
|
||
<div id="cards-list" class="overflow-x-auto">
|
||
<table class="w-full text-sm">
|
||
<thead><tr>
|
||
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="Name">Name</th>
|
||
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="ICCID">ICCID</th>
|
||
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">KIc</th>
|
||
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">KID</th>
|
||
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">SPI1</th>
|
||
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">SPI2</th>
|
||
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">TAR</th>
|
||
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="Cntr">Cntr</th>
|
||
<th class="border-b border-gray-200 dark:border-slate-700"></th>
|
||
</tr></thead>
|
||
<tbody id="cards-tbody"></tbody>
|
||
</table>
|
||
</div>
|
||
<input type="file" id="cards-file" accept=".json,application/json" class="hidden" onchange="cardsImportFile(this)">
|
||
<div class="flex flex-wrap gap-2 mt-2">
|
||
<button onclick="cardsExport()" class="px-2 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Export as JSON">Export as JSON</button>
|
||
<button onclick="cardsExportFile()" class="px-2 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Export to file">Export to file</button>
|
||
<button onclick="document.getElementById('cards-file').click()" class="px-2 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700" data-l10n="Import from file">Import from file</button>
|
||
<button onclick="cardsImportField()" class="px-2 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700" data-l10n="Paste & import">Paste & import</button>
|
||
<button onclick="cardsImport()" class="px-2 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700" data-l10n="Import JSON from clipboard">Import JSON from clipboard</button>
|
||
</div>
|
||
<textarea id="cards-io" 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 hidden" placeholder="JSON data"></textarea>
|
||
</div>
|
||
|
||
<div id="tab-pysim" class="tab-content hidden">
|
||
<div id="pysim-connect-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Server URL">Server URL</label>
|
||
<div class="flex gap-2 items-center">
|
||
<input id="pysim-url" class="font-mono flex-1 max-w-md border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 dark:bg-slate-800" value="http://127.0.0.1:8080">
|
||
<button onclick="pysimConnect()" id="pysim-connect-btn" class="px-4 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700" data-l10n="Connect">Connect</button>
|
||
</div>
|
||
</div>
|
||
<div id="pysim-connect-info" class="mb-3 text-xs text-gray-500 dark:text-slate-400 leading-relaxed">
|
||
<p data-l10n="To access local card reader, you need pysim-otaman-server running locally.">To access local card reader, you need pysim-otaman-server running locally.</p>
|
||
<p><span data-l10n="To connect, install">To connect, install</span> <a href="https://github.com/anttro/otaman" target="_blank" class="text-blue-600 dark:text-blue-400 underline">pysim-otaman-server</a>: <code class="font-mono bg-gray-100 dark:bg-slate-700 px-1 rounded">git clone https://github.com/anttro/otaman.git</code> <span data-l10n="and start it.">and start it.</span><br><span data-l10n="Refer to pysim-otaman-server README files for details.">Refer to pysim-otaman-server README files for details.</span> <span data-l10n="Click">Click</span> <b data-l10n="Connect">Connect</b> <span data-l10n="when pysim-otaman-server is ready">when pysim-otaman-server is ready</span></p>
|
||
<div id="pysim-connect-msg" class="text-xs"></div>
|
||
</div>
|
||
<div id="pysim-connected-row" class="mb-3 hidden">
|
||
<div class="flex gap-3 items-start">
|
||
<img id="pysim-status-img" src="nosim.svg" alt="pysim" class="w-16 h-16 shrink-0 dark:invert">
|
||
<div class="flex-1 min-w-0">
|
||
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
||
<button onclick="pysimEquipCmd()" class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Equip card">Equip card</button>
|
||
<button onclick="pysimStatusCmd()" class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Check status">Check status</button>
|
||
<button onclick="pysimResetCmd()" class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Reset card">Reset card</button>
|
||
<button onclick="pysimDisconnect()" id="pysim-disconnect-btn" class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Disconnect from pySim">Disconnect from pySim</button>
|
||
</div>
|
||
<div id="pysim-status" class="text-xs font-mono text-gray-600 dark:text-slate-400"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="pysim-body" class="hidden">
|
||
<div class="flex gap-1 mb-2">
|
||
<button class="pysim-subtab px-3 py-1 text-sm rounded-full bg-blue-600 text-white" data-pysim-sub="files" data-l10n="File manager">File manager</button>
|
||
<button class="pysim-subtab px-3 py-1 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-pysim-sub="custom" data-l10n="Custom files">Custom files</button>
|
||
<button class="pysim-subtab px-3 py-1 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-pysim-sub="cmd" data-l10n="pySim command line">pySim command line</button>
|
||
<button class="pysim-subtab px-3 py-1 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-pysim-sub="apdu" data-l10n="Raw APDU">Raw APDU</button>
|
||
<button class="pysim-subtab px-3 py-1 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-pysim-sub="proactive">Proactive UICC</button>
|
||
<button id="stk-menu-btn" onclick="stkMenuOpen()" class="pysim-subtab px-3 py-1 text-sm rounded-full bg-emerald-600 text-white hover:bg-emerald-700" style="display:none">STK: <span id="stk-menu-title"></span></button>
|
||
</div>
|
||
|
||
<div id="pysim-sub-files">
|
||
<div class="flex gap-2">
|
||
<div class="w-1/3 border border-gray-300 dark:border-slate-600 rounded p-2 text-xs font-mono overflow-y-auto bg-gray-50 dark:bg-slate-800" style="max-height:420px">
|
||
<div id="pysim-fs-tree"></div>
|
||
</div>
|
||
<div class="flex-1">
|
||
<div id="pysim-fs-detail" class="hidden">
|
||
<div class="text-xs font-mono mb-1"><span data-l10n="File:">File:</span> <b id="pysim-fs-filename"></b></div>
|
||
<div id="pysim-fs-content" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1.5 bg-gray-100 dark:bg-slate-800 min-h-20 overflow-y-auto"></div>
|
||
<div class="flex gap-2 mt-1 items-center">
|
||
<button onclick="pysimFsRead()" id="pysim-fs-read-btn" class="px-2 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Read">Read</button>
|
||
<button onclick="pysimFsSave()" id="pysim-fs-save-btn" class="px-2 py-1 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 hidden" data-l10n="Save">Save</button>
|
||
<button onclick="pysimFsCancel()" id="pysim-fs-cancel-btn" class="px-2 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700 hidden" data-l10n="Cancel">Cancel</button>
|
||
<span class="text-xs text-gray-400 dark:text-slate-500">|</span>
|
||
<span class="pysim-mode-pill px-2 py-0.5 text-xs rounded cursor-pointer bg-blue-600 text-white" data-mode="raw" onclick="pysimFsSetMode('raw')" data-l10n="Raw">Raw</span>
|
||
<span class="pysim-mode-pill px-2 py-0.5 text-xs rounded cursor-pointer bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300" data-mode="dec" onclick="pysimFsSetMode('dec')" data-l10n="Decoded">Decoded</span>
|
||
<button onclick="pysimFsEdit()" id="pysim-fs-edit-btn" class="px-2 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 ml-auto" data-l10n="Edit">Edit</button>
|
||
</div>
|
||
<div id="pysim-fs-status" class="mt-1 text-xs font-mono text-gray-500 dark:text-slate-400" style="min-height:1.2em"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="pysim-sub-custom" class="hidden">
|
||
<div class="mb-2 text-xs font-mono text-gray-600 dark:text-slate-400" data-l10n="Add known file paths and aliases to the file tree.">Add known file paths and aliases to the file tree.</div>
|
||
<div class="flex gap-2 items-center mb-2">
|
||
<input id="pysim-cf-path" class="font-mono flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 dark:bg-slate-800" placeholder="3F00/6F46" onkeydown="if(event.key==='Enter')pysimCustomAdd()">
|
||
<input id="pysim-cf-name" class="font-mono w-48 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 dark:bg-slate-800" placeholder="EF.SPN" onkeydown="if(event.key==='Enter')pysimCustomAdd()">
|
||
<button onclick="pysimCustomAdd()" class="px-3 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 whitespace-nowrap" data-l10n="Add">Add</button>
|
||
</div>
|
||
<div id="pysim-cf-list" class="text-xs font-mono text-gray-600 dark:text-slate-400"></div>
|
||
<input type="file" id="pysim-cf-file" accept=".json,application/json" class="hidden" onchange="pysimCustomImportFile(this)">
|
||
<div class="flex flex-wrap gap-2 mt-2">
|
||
<button onclick="pysimCustomExport()" class="px-2 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Export as JSON">Export as JSON</button>
|
||
<button onclick="pysimCustomExportFile()" class="px-2 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Export to file">Export to file</button>
|
||
<button onclick="document.getElementById('pysim-cf-file').click()" class="px-2 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700" data-l10n="Import from file">Import from file</button>
|
||
<button onclick="pysimCustomImportField()" class="px-2 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700" data-l10n="Paste & import">Paste & import</button>
|
||
<button onclick="pysimCustomImport()" class="px-2 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700" data-l10n="Import JSON from clipboard">Import JSON from clipboard</button>
|
||
</div>
|
||
<textarea id="pysim-cf-io" class="hidden mt-2 w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1.5 bg-gray-100 dark:bg-slate-800" rows="6"></textarea>
|
||
</div>
|
||
|
||
<div id="pysim-sub-cmd" class="hidden">
|
||
<div class="flex gap-2 items-center mb-2">
|
||
<div class="relative flex-1">
|
||
<input id="pysim-cmd" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 dark:bg-slate-800" placeholder="select MF" autocomplete="off" onkeydown="pysimCmdKeydown(event)" oninput="pysimCmdInput()">
|
||
<div id="pysim-cmd-suggest" class="hidden absolute bottom-full left-0 right-0 mb-1 max-h-32 overflow-y-auto border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-800 text-xs font-mono shadow-lg z-10"></div>
|
||
</div>
|
||
<button onclick="pysimExec()" class="px-4 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700" data-l10n="Execute">Execute</button>
|
||
</div>
|
||
<div id="pysim-cmd-hint" class="hidden mb-2 text-xs font-mono text-gray-500 dark:text-slate-400 border border-gray-200 dark:border-slate-700 rounded p-2 bg-gray-50 dark:bg-slate-800 whitespace-pre-wrap"></div>
|
||
<textarea id="pysim-cmd-output" rows="10" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 bg-gray-100 dark:bg-slate-800"></textarea>
|
||
</div>
|
||
|
||
<div id="pysim-sub-apdu" class="hidden">
|
||
<div class="flex gap-2 items-center mb-2">
|
||
<input id="pysim-apdu" class="font-mono flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 dark:bg-slate-800" placeholder="00A4040000" onkeydown="if(event.key==='Enter')pysimApdu()">
|
||
<button onclick="pysimApdu()" class="px-4 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700" data-l10n="Send">Send</button>
|
||
</div>
|
||
<textarea id="pysim-apdu-output" rows="5" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 bg-gray-100 dark:bg-slate-800"></textarea>
|
||
</div>
|
||
<div id="pysim-sub-proactive" class="hidden">
|
||
<div class="flex gap-6">
|
||
<div>
|
||
<div class="mb-1 text-sm text-gray-500 dark:text-slate-400" data-l10n="STATUS and Polling">STATUS and Polling</div>
|
||
<div class="flex items-center gap-2 mb-3">
|
||
<button id="pli-status-btn" onclick="pysimStatusPoll()" class="px-2.5 py-1 text-sm rounded bg-gray-600 text-white hover:bg-gray-700" data-l10n="Send STATUS">Send STATUS</button>
|
||
<span class="text-xs text-gray-500 dark:text-slate-400">|</span>
|
||
<span class="text-xs text-gray-500 dark:text-slate-400" data-l10n="Polling">Polling</span>
|
||
<button id="pli-pause-btn" onclick="pysimPollToggle()" class="px-2.5 py-1 text-sm rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600">OFF</button>
|
||
<span id="pli-poll-stat" class="text-xs text-gray-400"></span>
|
||
</div>
|
||
<div class="mt-2 mb-2 text-sm text-gray-500 dark:text-slate-400" data-l10n="Events that the card monitors (SET UP EVENT LIST):">Events that the card monitors (SET UP EVENT LIST):</div>
|
||
<div id="pysim-event-list" class="text-sm font-mono text-gray-600 dark:text-slate-400"></div>
|
||
<div class="mt-4 mb-2 text-sm text-gray-500 dark:text-slate-400" data-l10n="Fetched proactive commands:">Fetched proactive commands:</div>
|
||
<div id="pysim-proactive-log" class="text-sm font-mono text-gray-600 dark:text-slate-400"></div>
|
||
</div>
|
||
<div>
|
||
<div class="flex justify-between items-center mb-2">
|
||
<span class="text-sm text-gray-500 dark:text-slate-400" data-l10n="PROVIDE LOCAL INFORMATION response data">PROVIDE LOCAL INFORMATION response data</span>
|
||
<button id="pli-save-btn" onclick="pysimPliSave()" class="px-2.5 py-1 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700" data-l10n="Save">Save</button>
|
||
</div>
|
||
<div id="pysim-pli-dict" class="text-sm font-mono text-gray-600 dark:text-slate-400 max-h-[60vh] overflow-auto">
|
||
<span class="text-gray-400" data-l10n="Loading...">Loading...</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="event-send-modal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden">
|
||
<div class="bg-white dark:bg-slate-800 rounded-lg p-6 max-w-sm w-full mx-4 max-h-[85vh] overflow-auto">
|
||
<h3 id="event-send-title" class="text-lg font-semibold mb-4 text-gray-800 dark:text-slate-200"></h3>
|
||
<div id="event-send-body" class="space-y-3"></div>
|
||
<div id="event-send-error" class="text-xs text-red-500 hidden mt-2"></div>
|
||
<div id="event-send-result" class="text-xs text-emerald-600 dark:text-emerald-400 hidden mt-2"></div>
|
||
<div class="flex gap-2 mt-4 justify-end">
|
||
<button onclick="document.getElementById('event-send-modal').classList.add('hidden')" class="px-3 py-1.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-100 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Cancel">Cancel</button>
|
||
<button id="event-send-btn" onclick="pysimEventSend()" class="px-4 py-1.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Send">Send</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="stk-menu-panel" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden">
|
||
<div class="bg-white dark:bg-slate-800 rounded-lg p-4 max-w-xs w-full max-h-[80vh] flex flex-col">
|
||
<div class="flex justify-between items-center mb-3">
|
||
<button id="stk-back-btn" onclick="stkMenuRespond('back')" class="px-3 py-1 text-xs 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" style="display:none" data-l10n="← Back">← Back</button>
|
||
<span id="stk-menu-panel-title" class="font-bold text-sm flex-1 text-center"></span>
|
||
<button onclick="stkMenuExit()" class="px-3 py-1 text-xs rounded-full bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-900/50" data-l10n="Exit">Exit</button>
|
||
</div>
|
||
<div id="stk-menu-panel-content" class="flex-1 overflow-auto"></div>
|
||
<div id="stk-menu-buttons" class="flex justify-between mt-3 pt-3 border-t border-gray-200 dark:border-slate-700 hidden">
|
||
<button id="stk-timeout-btn" onclick="stkMenuRespond('timeout')" class="px-3 py-1 text-xs 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-l10n="Timeout">Timeout</button>
|
||
<button id="stk-cancel-btn" onclick="stkMenuRespond('cancel')" class="px-3 py-1 text-xs 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-l10n="Cancel">Cancel</button>
|
||
<button id="stk-ok-btn" onclick="stkMenuRespond('ok')" class="px-4 py-1 text-xs rounded-full bg-blue-600 text-white hover:bg-blue-700" data-l10n="OK">OK</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<script>
|
||
// ===== Tab switching =====
|
||
function switchTab(name) {
|
||
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
|
||
document.getElementById('tab-' + name).classList.remove('hidden');
|
||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||
btn.classList.remove('bg-blue-600', 'text-white', 'dark:bg-blue-500', 'dark:text-white');
|
||
btn.classList.add('bg-gray-200', 'text-gray-700', 'dark:bg-slate-700', 'dark:text-slate-300');
|
||
});
|
||
const active = document.querySelector(`.tab-btn[data-tab="${name}"]`);
|
||
active.classList.remove('bg-gray-200', 'text-gray-700', 'dark:bg-slate-700', 'dark:text-slate-300');
|
||
active.classList.add('bg-blue-600', 'text-white', 'dark:bg-blue-500', 'dark:text-white');
|
||
if (name === 'c-apdu') cApduSwitchSubtab('sim');
|
||
else if (name === 'sp') setHelpAnchor('secured-packet');
|
||
else if (name === 'response') setHelpAnchor('response-parser');
|
||
else if (name === 'cards') setHelpAnchor('cards');
|
||
else if (name === 'pysim') setHelpAnchor(pysimHelpAnchor);
|
||
}
|
||
|
||
function cApduSwitchSubtab(name) {
|
||
document.querySelectorAll('.c-apdu-subtab').forEach(btn => {
|
||
const active = btn.dataset.sub === name;
|
||
btn.classList.toggle('bg-blue-600', active);
|
||
btn.classList.toggle('text-white', active);
|
||
btn.classList.toggle('bg-gray-200', !active);
|
||
btn.classList.toggle('dark:bg-slate-700', !active);
|
||
btn.classList.toggle('text-gray-700', !active);
|
||
btn.classList.toggle('dark:text-slate-300', !active);
|
||
});
|
||
['sim', 'usim', 'ber', 'ram', 'parse'].forEach(s => {
|
||
const el = document.getElementById('c-apdu-sub-' + s);
|
||
if (el) el.classList.toggle('hidden', s !== name);
|
||
});
|
||
if (name === 'sim' || name === 'usim') {
|
||
updateSimUsimFields(name);
|
||
updateSimSelectMethod(name);
|
||
}
|
||
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));
|
||
});
|
||
|
||
// ===== SIM/USIM field visibility =====
|
||
const RECORD_OPS = new Set(['update-record', 'read-record']);
|
||
const BINARY_OPS = new Set(['update-binary', 'read-binary', 'erase-binary']);
|
||
const READ_OPS = new Set(['read-record', 'read-binary']);
|
||
const DATA_OPS = new Set(['update-record', 'update-binary']);
|
||
const RECSIZE_OPS = new Set(['update-record']);
|
||
const PIN_OPS = new Set(['verify', 'change', 'disable', 'enable', 'unblock']);
|
||
|
||
function updateSimUsimFields(mode) {
|
||
if (!mode) return;
|
||
const cmd = document.getElementById(mode + '-cmd').value;
|
||
document.getElementById(mode + '-record-row').style.display = RECORD_OPS.has(cmd) ? '' : 'none';
|
||
document.getElementById(mode + '-offset-row').style.display = BINARY_OPS.has(cmd) ? '' : 'none';
|
||
document.getElementById(mode + '-le-row').style.display = READ_OPS.has(cmd) ? '' : 'none';
|
||
document.getElementById(mode + '-data-row').style.display = DATA_OPS.has(cmd) ? '' : 'none';
|
||
document.getElementById(mode + '-recsize-row').style.display = RECSIZE_OPS.has(cmd) ? '' : 'none';
|
||
document.getElementById(mode + '-pin-row').style.display = PIN_OPS.has(cmd) ? '' : 'none';
|
||
const pinValCmds = cmd === 'verify' || cmd === 'disable' || cmd === 'enable';
|
||
document.getElementById(mode + '-pin-val-row').style.display = pinValCmds ? '' : 'none';
|
||
const oldNewCmds = cmd === 'change' || cmd === 'unblock';
|
||
document.getElementById(mode + '-pin-old-row').style.display = oldNewCmds ? '' : 'none';
|
||
document.getElementById(mode + '-pin-new-row').style.display = oldNewCmds ? '' : 'none';
|
||
if (oldNewCmds) {
|
||
document.querySelector('#' + mode + '-pin-old-row label').textContent = cmd === 'unblock' ? 'Unblock PIN' : 'Old PIN';
|
||
}
|
||
document.getElementById(mode + '-act-row').style.display = (cmd === 'activate-file' || cmd === 'deactivate-file') ? '' : 'none';
|
||
if (cmd === 'select') {
|
||
document.getElementById(mode + '-select-checkbox-row').style.display = 'none';
|
||
} else {
|
||
document.getElementById(mode + '-select-checkbox-row').style.display = '';
|
||
}
|
||
updateP1P2Display(mode);
|
||
}
|
||
|
||
function updateSimSelectMethod(mode) {
|
||
if (!mode) return;
|
||
const method = document.getElementById(mode + '-sel-method').value;
|
||
document.getElementById(mode + '-sel-fid-row').style.display = method === 'fid' ? '' : 'none';
|
||
document.getElementById(mode + '-sel-path-row').style.display = method === 'path' ? '' : 'none';
|
||
document.getElementById(mode + '-sel-dfname-row').style.display = method === 'dfname' ? '' : 'none';
|
||
document.getElementById(mode + '-sel-chain-row').style.display = method === 'chain' ? '' : 'none';
|
||
}
|
||
|
||
function updateActFileInput(mode) {
|
||
const target = document.getElementById(mode + '-act-target').value;
|
||
document.getElementById(mode + '-act-file').style.display = target === 'current' ? 'none' : '';
|
||
}
|
||
|
||
function toggleSimOverride(mode) {
|
||
const checked = document.getElementById(mode + '-override').checked;
|
||
document.getElementById(mode + '-p1').disabled = !checked;
|
||
document.getElementById(mode + '-p2').disabled = !checked;
|
||
}
|
||
|
||
function updateSimSelectSection(mode) {
|
||
const cmd = document.getElementById(mode + '-cmd').value;
|
||
if (cmd === 'select') return;
|
||
document.getElementById(mode + '-select-method-body').style.display =
|
||
document.getElementById(mode + '-select').checked ? '' : 'none';
|
||
}
|
||
|
||
function updateP1P2Display(mode) {
|
||
const preset = mode === 'sim' ? {selP1: 0x00, selP2: 0x00} : {selP1: 0x00, selP2: 0x0C};
|
||
const cmd = document.getElementById(mode + '-cmd').value;
|
||
if (cmd === 'select') {
|
||
const method = document.getElementById(mode + '-sel-method').value;
|
||
let p1, p2;
|
||
if (method === 'fid' || method === 'chain') {
|
||
p1 = preset.selP1; p2 = preset.selP2;
|
||
} else if (method === 'path') {
|
||
p1 = 0x08; p2 = mode === 'sim' ? 0x00 : 0x04;
|
||
} else if (method === 'dfname') {
|
||
p1 = 0x04; p2 = mode === 'sim' ? 0x00 : 0x04;
|
||
} else {
|
||
p1 = preset.selP1; p2 = preset.selP2;
|
||
}
|
||
document.getElementById(mode + '-p1').value = p1.toString(16).padStart(2, '0').toUpperCase();
|
||
document.getElementById(mode + '-p2').value = p2.toString(16).padStart(2, '0').toUpperCase();
|
||
return;
|
||
}
|
||
const record = parseInt(document.getElementById(mode + '-record').value) || 1;
|
||
const recMode = parseInt(document.getElementById(mode + '-rec-mode').value, 16);
|
||
const offsetHex = (document.getElementById(mode + '-offset').value || '0000').replace(/[^0-9a-fA-F]/g, '').padStart(4, '0').slice(0, 4);
|
||
const offset = parseInt(offsetHex, 16);
|
||
const pin = document.getElementById(mode + '-pin').value;
|
||
let p1, p2;
|
||
if (cmd === 'read-record' || cmd === 'update-record') {
|
||
p1 = (recMode === 0x02 || recMode === 0x03) ? 0x00 : record; p2 = recMode;
|
||
} else if (cmd === 'read-binary' || cmd === 'update-binary' || cmd === 'erase-binary') {
|
||
p1 = (offset >> 8) & 0xFF; p2 = offset & 0xFF;
|
||
} else if (cmd === 'activate-file' || cmd === 'deactivate-file') {
|
||
const target = document.getElementById(mode + '-act-target').value;
|
||
if (target === 'current') {
|
||
p1 = 0x00; p2 = 0x00;
|
||
} else {
|
||
p1 = target === 'fid' ? 0x00 : (target === 'mfpath' ? 0x08 : 0x09);
|
||
p2 = 0x00;
|
||
}
|
||
} else if (cmd === 'verify' || cmd === 'change' || cmd === 'disable' || cmd === 'enable' || cmd === 'unblock') {
|
||
p1 = 0x00; p2 = parseInt(pin, 16);
|
||
} else {
|
||
p1 = 0x00; p2 = 0x00;
|
||
}
|
||
document.getElementById(mode + '-p1').value = p1.toString(16).padStart(2, '0').toUpperCase();
|
||
document.getElementById(mode + '-p2').value = p2.toString(16).padStart(2, '0').toUpperCase();
|
||
}
|
||
|
||
// ===== RAM field visibility =====
|
||
function updateRamFields() {
|
||
const cmd = document.getElementById('ram-cmd').value;
|
||
const showPriv = ['install-install', 'install-make-sel', 'install-reg-update'].includes(cmd);
|
||
document.getElementById('ram-priv-row').style.display = showPriv ? '' : 'none';
|
||
document.getElementById('ram-toolkit-row').style.display = cmd === 'install-install' ? '' : 'none';
|
||
document.getElementById('ram-sdaid-row').style.display = ['install-load', 'install-reg-update', 'install-extradition'].includes(cmd) ? '' : 'none';
|
||
document.getElementById('ram-elfaid-row').style.display = cmd === 'install-install' ? '' : 'none';
|
||
document.getElementById('ram-modaid-row').style.display = cmd === 'install-install' ? '' : 'none';
|
||
document.getElementById('ram-load-data-row').style.display = cmd === 'install-load' ? '' : 'none';
|
||
document.getElementById('ram-data-row').style.display = ['load', 'store-data', 'ext-auth', 'int-auth'].includes(cmd) ? '' : 'none';
|
||
document.getElementById('ram-block-row').style.display = ['load', 'store-data'].includes(cmd) ? '' : 'none';
|
||
document.getElementById('ram-enc-row').style.display = cmd === 'store-data' ? '' : 'none';
|
||
document.getElementById('ram-authp1-row').style.display = cmd === 'ext-auth' ? '' : 'none';
|
||
document.getElementById('ram-del-mode-row').style.display = cmd === 'delete' ? '' : 'none';
|
||
document.getElementById('ram-gs-mode-row').style.display = cmd === 'get-status' ? '' : 'none';
|
||
document.getElementById('ram-gs-p2-row').style.display = cmd === 'get-status' ? '' : 'none';
|
||
document.getElementById('ram-tag-row').style.display = cmd === 'get-data' ? '' : 'none';
|
||
document.getElementById('ram-ss-mode-row').style.display = cmd === 'set-status' ? '' : 'none';
|
||
document.getElementById('ram-ss-state-row').style.display = cmd === 'set-status' ? '' : 'none';
|
||
updateRamToolkit();
|
||
}
|
||
|
||
function updateRamPriv() {
|
||
let b1 = 0, b2 = 0, b3 = 0;
|
||
document.querySelectorAll('.ram-priv-b1:checked').forEach(cb => b1 |= parseInt(cb.value, 16));
|
||
document.querySelectorAll('.ram-priv-b2:checked').forEach(cb => b2 |= parseInt(cb.value, 16));
|
||
document.querySelectorAll('.ram-priv-b3:checked').forEach(cb => b3 |= parseInt(cb.value, 16));
|
||
let hex = b1.toString(16).padStart(2, '0').toUpperCase();
|
||
hex += b2.toString(16).padStart(2, '0').toUpperCase();
|
||
if (b3) hex += b3.toString(16).padStart(2, '0').toUpperCase();
|
||
document.getElementById('ram-priv').value = hex;
|
||
}
|
||
|
||
function updateRamToolkit() {
|
||
const checked = document.getElementById('ram-toolkit-enable').checked;
|
||
document.getElementById('ram-toolkit-body').style.display = checked ? '' : 'none';
|
||
updateRamTkMode();
|
||
}
|
||
function updateRamTkMode() {
|
||
const isSim = document.getElementById('ram-tk-mode').value === 'ca';
|
||
document.getElementById('ram-tk-ad-row').style.display = isSim ? '' : 'none';
|
||
document.getElementById('ram-tk-services-row').style.display = isSim ? 'none' : '';
|
||
}
|
||
document.getElementById('ram-tk-mode').addEventListener('change', updateRamTkMode);
|
||
|
||
// ===== GSM 7-bit alphabet =====
|
||
const GSM7_ALPHABET = [
|
||
'\u0040','\u00a3','\u0024','\u00a5','\u00e8','\u00e9','\u00f9','\u00ec',
|
||
'\u00f2','\u00c7','\u000a','\u00d8','\u00f8','\u000d','\u00c5','\u00e5',
|
||
'\u0394','\u005f','\u03a6','\u0393','\u039b','\u03a9','\u03a0','\u03a8',
|
||
'\u03a3','\u0398','\u039e','\u001b','\u00c6','\u00e6','\u00df','\u00c9',
|
||
'\u0020','\u0021','\u0022','\u0023','\u00a4','\u0025','\u0026','\u0027',
|
||
'\u0028','\u0029','\u002a','\u002b','\u002c','\u002d','\u002e','\u002f',
|
||
'\u0030','\u0031','\u0032','\u0033','\u0034','\u0035','\u0036','\u0037',
|
||
'\u0038','\u0039','\u003a','\u003b','\u003c','\u003d','\u003e','\u003f',
|
||
'\u00a1','\u0041','\u0042','\u0043','\u0044','\u0045','\u0046','\u0047',
|
||
'\u0048','\u0049','\u004a','\u004b','\u004c','\u004d','\u004e','\u004f',
|
||
'\u0050','\u0051','\u0052','\u0053','\u0054','\u0055','\u0056','\u0057',
|
||
'\u0058','\u0059','\u005a','\u00c4','\u00d6','\u00d1','\u00dc','\u00a7',
|
||
'\u00bf','\u0061','\u0062','\u0063','\u0064','\u0065','\u0066','\u0067',
|
||
'\u0068','\u0069','\u006a','\u006b','\u006c','\u006d','\u006e','\u006f',
|
||
'\u0070','\u0071','\u0072','\u0073','\u0074','\u0075','\u0076','\u0077',
|
||
'\u0078','\u0079','\u007a','\u00e4','\u00f6','\u00f1','\u00fc','\u00e0',
|
||
];
|
||
|
||
const GSM7_EXT_MAP = {
|
||
'\n': 0x0a, '^': 0x14, '{': 0x28, '}': 0x29, '\\': 0x2f,
|
||
'[': 0x3c, '~': 0x3d, ']': 0x3e, '|': 0x40, '\u20ac': 0x65,
|
||
};
|
||
|
||
function gsm7TextToSeptets(text) {
|
||
const septets = [];
|
||
for (const c of text) {
|
||
if (c in GSM7_EXT_MAP) {
|
||
septets.push(0x1B);
|
||
septets.push(GSM7_EXT_MAP[c]);
|
||
} else {
|
||
const idx = GSM7_ALPHABET.indexOf(c);
|
||
if (idx === -1) throw new Error('Character not in GSM7 alphabet');
|
||
septets.push(idx);
|
||
}
|
||
}
|
||
return septets;
|
||
}
|
||
|
||
function gsm7Encode(septets) {
|
||
const numOctets = Math.ceil(septets.length * 7 / 8);
|
||
const packed = new Uint8Array(numOctets);
|
||
for (let i = 0; i < septets.length; i++) {
|
||
const bitPos = i * 7;
|
||
const byteIdx = bitPos >>> 3;
|
||
const bitShift = bitPos & 7;
|
||
packed[byteIdx] |= (septets[i] << bitShift) & 0xFF;
|
||
if (byteIdx + 1 < numOctets) {
|
||
packed[byteIdx + 1] |= septets[i] >>> (8 - bitShift);
|
||
}
|
||
}
|
||
return packed;
|
||
}
|
||
|
||
function encodeSpn(text) {
|
||
try {
|
||
const septets = gsm7TextToSeptets(text);
|
||
const packed = gsm7Encode(septets);
|
||
const nSeptets = septets.length;
|
||
const nOctets = packed.length;
|
||
const lastOctetDataBits = nSeptets * 7 - (nOctets - 1) * 8;
|
||
const spareBits = 8 - lastOctetDataBits;
|
||
let dcs = 0x80 | (spareBits & 0x07);
|
||
if (dcs === 0x81) dcs = 0x80;
|
||
const inner = new Uint8Array(16);
|
||
inner[0] = dcs;
|
||
inner.set(packed, 1);
|
||
for (let i = 1 + nOctets; i < 16; i++) inner[i] = 0xFF;
|
||
return '01' + Array.from(inner).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join('');
|
||
} catch (e) {
|
||
let hasNonBmp = false;
|
||
for (const c of text) {
|
||
const code = c.charCodeAt(0);
|
||
if (code >= 0xD800 && code <= 0xDBFF) { hasNonBmp = true; break; }
|
||
}
|
||
if (hasNonBmp) {
|
||
const buf = [];
|
||
for (const c of text) {
|
||
const code = c.charCodeAt(0);
|
||
buf.push(code >>> 8, code & 0xFF);
|
||
}
|
||
const inner = new Uint8Array(16);
|
||
inner[0] = 0x80;
|
||
for (let i = 0; i < buf.length && i + 1 < 16; i++) inner[i + 1] = buf[i];
|
||
for (let i = 1 + buf.length; i < 16; i++) inner[i] = 0xFF;
|
||
return '00' + Array.from(inner).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join('');
|
||
} else {
|
||
const nonGsm7 = [];
|
||
for (const c of text) {
|
||
if (GSM7_ALPHABET.indexOf(c) === -1 && !(c in GSM7_EXT_MAP)) {
|
||
nonGsm7.push(c.charCodeAt(0));
|
||
}
|
||
}
|
||
const base = nonGsm7.length ? (Math.min(...nonGsm7) & 0xFF00) : 0;
|
||
const baseHigh = base >>> 8;
|
||
const inner = new Uint8Array(16);
|
||
inner[0] = 0x81;
|
||
inner[1] = text.length;
|
||
inner[2] = baseHigh;
|
||
let pos = 3;
|
||
for (const c of text) {
|
||
if (GSM7_EXT_MAP.hasOwnProperty(c)) {
|
||
inner[pos++] = GSM7_EXT_MAP[c];
|
||
} else {
|
||
const idx = GSM7_ALPHABET.indexOf(c);
|
||
if (idx !== -1) {
|
||
inner[pos++] = idx;
|
||
} else {
|
||
const code = c.charCodeAt(0);
|
||
inner[pos++] = 0x80 | (code - base);
|
||
}
|
||
}
|
||
}
|
||
while (pos < 16) inner[pos++] = 0xFF;
|
||
return '00' + Array.from(inner).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join('');
|
||
}
|
||
}
|
||
}
|
||
|
||
function swapNibbles(s) {
|
||
let out = '';
|
||
for (let i = 0; i < s.length; i += 2) {
|
||
out += s[i + 1] || 'f';
|
||
out += s[i];
|
||
}
|
||
return out.toUpperCase();
|
||
}
|
||
|
||
function encImsi(imsi) {
|
||
const l = Math.ceil((imsi.length + 1) / 2);
|
||
const oe = imsi.length & 1;
|
||
const indicator = ((oe << 3) | 1).toString(16);
|
||
const padded = imsi.padEnd(15, 'f');
|
||
const second = swapNibbles(indicator + padded);
|
||
return l.toString(16).padStart(2, '0') + second;
|
||
}
|
||
|
||
function encMsisdn(msisdn) {
|
||
let s = msisdn.replace(/^\+/, '');
|
||
if (s.length % 2) s += 'f';
|
||
return swapNibbles(s);
|
||
}
|
||
|
||
function encIccid(iccid) {
|
||
return swapNibbles(iccid);
|
||
}
|
||
|
||
function buildApdu(cla, ins, p1, p2, p3, data) {
|
||
let s = cla.toString(16).padStart(2, '0').toUpperCase() +
|
||
ins.toString(16).padStart(2, '0').toUpperCase() +
|
||
p1.toString(16).padStart(2, '0').toUpperCase() +
|
||
p2.toString(16).padStart(2, '0').toUpperCase();
|
||
if (p3 !== null && p3 !== undefined) s += p3.toString(16).padStart(2, '0').toUpperCase();
|
||
if (data) s += data.toUpperCase();
|
||
return s;
|
||
}
|
||
|
||
function buildSelect(cla, p1, p2, fid) {
|
||
const data = fid.length === 4 ? fid : '0000';
|
||
return buildApdu(cla, 0xA4, p1, p2, 0x02, data);
|
||
}
|
||
|
||
function buildOpNoCla(ins, p1, p2, p3, data) {
|
||
let s = ins.toString(16).padStart(2, '0').toUpperCase() +
|
||
p1.toString(16).padStart(2, '0').toUpperCase() +
|
||
p2.toString(16).padStart(2, '0').toUpperCase();
|
||
if (p3 !== null && p3 !== undefined) s += p3.toString(16).padStart(2, '0').toUpperCase();
|
||
if (data) s += data.toUpperCase();
|
||
return s;
|
||
}
|
||
|
||
const OPS = {
|
||
'select': {ins: 0xA4},
|
||
'read-record': {ins: 0xB2},
|
||
'read-binary': {ins: 0xB0},
|
||
'update-record': {ins: 0xDC},
|
||
'update-binary': {ins: 0xD6},
|
||
'erase-binary': {ins: 0x0E},
|
||
'activate-file': {ins: 0x44},
|
||
'deactivate-file': {ins: 0x04},
|
||
'verify': {ins: 0x20},
|
||
'change': {ins: 0x24},
|
||
'disable': {ins: 0x26},
|
||
'enable': {ins: 0x28},
|
||
'unblock': {ins: 0x2C},
|
||
};
|
||
|
||
function getSelValue(mode) {
|
||
const method = document.getElementById(mode + '-sel-method').value;
|
||
if (method === 'fid') {
|
||
return {type: 'fid', value: (document.getElementById(mode + '-sel-fid').value || '').replace(/[^0-9a-fA-F]/g, '')};
|
||
} else if (method === 'path') {
|
||
return {type: 'path', value: (document.getElementById(mode + '-sel-path').value || '').replace(/[^0-9a-fA-F]/g, '')};
|
||
} else if (method === 'dfname') {
|
||
return {type: 'dfname', value: (document.getElementById(mode + '-sel-dfname').value || '').replace(/[^0-9a-fA-F]/g, '')};
|
||
} else if (method === 'chain') {
|
||
return {type: 'chain', value: (document.getElementById(mode + '-sel-chain').value || '').trim()};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function buildSelectApdu(mode, preset, selInfo) {
|
||
const isSim = preset.cla === 0xA0;
|
||
if (!selInfo || !selInfo.value) return '';
|
||
if (selInfo.type === 'fid') {
|
||
if (selInfo.value.length !== 4) return '';
|
||
return buildSelect(preset.cla, preset.selP1, preset.selP2, selInfo.value);
|
||
} else if (selInfo.type === 'path') {
|
||
if (selInfo.value.length < 2 || selInfo.value.length % 2 !== 0) return '';
|
||
const p3 = selInfo.value.length / 2;
|
||
const p2 = isSim ? 0x00 : 0x04;
|
||
let apdu = buildApdu(preset.cla, 0xA4, 0x08, p2, p3, selInfo.value);
|
||
if (!isSim) apdu += '00';
|
||
return apdu;
|
||
} else if (selInfo.type === 'dfname') {
|
||
if (selInfo.value.length < 2) return '';
|
||
const p3 = selInfo.value.length / 2;
|
||
const p2 = isSim ? 0x00 : 0x04;
|
||
let apdu = buildApdu(preset.cla, 0xA4, 0x04, p2, p3, selInfo.value);
|
||
if (!isSim) apdu += '00';
|
||
return apdu;
|
||
} else if (selInfo.type === 'chain') {
|
||
const fids = selInfo.value.split(',').map(f => f.trim().replace(/[^0-9a-fA-F]/g, '')).filter(f => f.length === 4);
|
||
if (fids.length === 0) return '';
|
||
return fids.map(fid => buildSelect(preset.cla, preset.selP1, preset.selP2, fid)).join('');
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function genSimUsim(mode) {
|
||
const preset = mode === 'sim' ? {cla: 0xA0, selP1: 0x00, selP2: 0x00} : {cla: 0x00, selP1: 0x00, selP2: 0x0C};
|
||
const cmd = document.getElementById(mode + '-cmd').value;
|
||
const doSelect = document.getElementById(mode + '-select').checked;
|
||
const selInfo = getSelValue(mode);
|
||
const record = parseInt(document.getElementById(mode + '-record').value) || 1;
|
||
const recModeHex = document.getElementById(mode + '-rec-mode').value;
|
||
const recMode = parseInt(recModeHex, 16);
|
||
const offsetHex = (document.getElementById(mode + '-offset').value || '0000').replace(/[^0-9a-fA-F]/g, '').padStart(4, '0').slice(0, 4);
|
||
const offset = parseInt(offsetHex, 16);
|
||
const leHex = (document.getElementById(mode + '-le').value || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2);
|
||
const le = parseInt(leHex, 16);
|
||
let dataHex = (document.getElementById(mode + '-data').value || '').replace(/[^0-9a-fA-F]/g, '');
|
||
const recSize = parseInt(document.getElementById(mode + '-recsize').value) || 0;
|
||
const pin = document.getElementById(mode + '-pin').value;
|
||
const override = document.getElementById(mode + '-override').checked;
|
||
const overrideP1 = override ? parseInt((document.getElementById(mode + '-p1').value || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2), 16) : null;
|
||
const overrideP2 = override ? parseInt((document.getElementById(mode + '-p2').value || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2), 16) : null;
|
||
|
||
const resultEl = document.getElementById(mode + '-result');
|
||
|
||
if (recSize > 0 && dataHex.length > 0) {
|
||
const targetBytes = recSize * 2;
|
||
if (dataHex.length > targetBytes) dataHex = dataHex.slice(0, targetBytes);
|
||
else dataHex = dataHex.padEnd(targetBytes, 'F');
|
||
}
|
||
|
||
if (cmd === 'select') {
|
||
const selectApdu = buildSelectApdu(mode, preset, selInfo);
|
||
if (!selectApdu) { resultEl.value = 'Error: specify SELECT parameters'; return; }
|
||
resultEl.value = selectApdu;
|
||
document.getElementById(mode + '-pack-btn').disabled = false;
|
||
return;
|
||
}
|
||
|
||
const op = OPS[cmd];
|
||
if (!op) { resultEl.value = 'Error: unknown command'; return; }
|
||
|
||
let p1, p2, p3, data = null;
|
||
if (cmd === 'read-record') {
|
||
p1 = (recMode === 0x02 || recMode === 0x03) ? 0x00 : record; p2 = recMode; p3 = le;
|
||
} else if (cmd === 'read-binary') {
|
||
p1 = (offset >> 8) & 0xFF; p2 = offset & 0xFF; p3 = le;
|
||
} else if (cmd === 'update-record') {
|
||
if (!dataHex) { resultEl.value = 'Error: specify data'; return; }
|
||
p1 = (recMode === 0x02 || recMode === 0x03) ? 0x00 : record; p2 = recMode; p3 = dataHex.length / 2; data = dataHex;
|
||
} else if (cmd === 'update-binary') {
|
||
if (!dataHex) { resultEl.value = 'Error: specify data'; return; }
|
||
p1 = (offset >> 8) & 0xFF; p2 = offset & 0xFF; p3 = dataHex.length / 2; data = dataHex;
|
||
} else if (cmd === 'erase-binary') {
|
||
p1 = (offset >> 8) & 0xFF; p2 = offset & 0xFF; p3 = 0x00;
|
||
} else if (cmd === 'activate-file' || cmd === 'deactivate-file') {
|
||
const target = document.getElementById(mode + '-act-target').value;
|
||
if (target === 'current') {
|
||
p1 = 0x00; p2 = 0x00; p3 = null;
|
||
} else {
|
||
const fileHex = (document.getElementById(mode + '-act-file').value || '').replace(/[^0-9a-fA-F]/g, '');
|
||
if (!fileHex || fileHex.length % 2 !== 0) { resultEl.value = 'Error: specify FID or path (even hex length)'; return; }
|
||
p1 = target === 'fid' ? 0x00 : (target === 'mfpath' ? 0x08 : 0x09);
|
||
p2 = 0x00;
|
||
p3 = fileHex.length / 2;
|
||
data = fileHex;
|
||
}
|
||
} else if (cmd === 'verify' || cmd === 'disable' || cmd === 'enable') {
|
||
const pinVal = document.getElementById(mode + '-pin-val').value || '';
|
||
if (!pinVal) { resultEl.value = 'Error: specify PIN value'; return; }
|
||
const pinHex = Array.from(pinVal).map(c => c.charCodeAt(0).toString(16).padStart(2, '0')).join('').padEnd(16, 'F').slice(0, 16);
|
||
p1 = 0x00; p2 = parseInt(pin, 16); p3 = 0x08; data = pinHex;
|
||
} else if (cmd === 'change' || cmd === 'unblock') {
|
||
const oldPin = document.getElementById(mode + '-pin-old').value || '';
|
||
const newPin = document.getElementById(mode + '-pin-new').value || '';
|
||
if (!oldPin || !newPin) { resultEl.value = 'Error: specify old and new PIN'; return; }
|
||
const oldHex = Array.from(oldPin).map(c => c.charCodeAt(0).toString(16).padStart(2, '0')).join('').padEnd(16, 'F').slice(0, 16);
|
||
const newHex = Array.from(newPin).map(c => c.charCodeAt(0).toString(16).padStart(2, '0')).join('').padEnd(16, 'F').slice(0, 16);
|
||
p1 = 0x00; p2 = parseInt(pin, 16); p3 = 0x10; data = oldHex + newHex;
|
||
}
|
||
|
||
if (override) {
|
||
if (overrideP1 !== null) p1 = overrideP1;
|
||
if (overrideP2 !== null) p2 = overrideP2;
|
||
}
|
||
|
||
let apdu = '';
|
||
if (doSelect) {
|
||
const selectApdu = buildSelectApdu(mode, preset, selInfo);
|
||
if (selectApdu) {
|
||
apdu = selectApdu + buildOpNoCla(op.ins, p1, p2, p3, data);
|
||
} else {
|
||
apdu = buildApdu(preset.cla, op.ins, p1, p2, p3, data);
|
||
}
|
||
} else {
|
||
apdu = buildApdu(preset.cla, op.ins, p1, p2, p3, data);
|
||
}
|
||
|
||
resultEl.value = apdu;
|
||
document.getElementById(mode + '-pack-btn').disabled = false;
|
||
}
|
||
|
||
function genRam() {
|
||
const cmd = document.getElementById('ram-cmd').value;
|
||
const aid = (document.getElementById('ram-aid').value || '').replace(/[^0-9a-fA-F]/g, '');
|
||
const sdAid = (document.getElementById('ram-sdaid').value || '').replace(/[^0-9a-fA-F]/g, '');
|
||
const elfAid = (document.getElementById('ram-elfaid').value || '').replace(/[^0-9a-fA-F]/g, '');
|
||
const modAid = (document.getElementById('ram-modaid').value || '').replace(/[^0-9a-fA-F]/g, '');
|
||
const loadData = (document.getElementById('ram-load-data').value || '').replace(/[^0-9a-fA-F]/g, '');
|
||
const ramData = (document.getElementById('ram-data').value || '').replace(/[^0-9a-fA-F]/g, '');
|
||
const blockNum = parseInt(document.getElementById('ram-block').value) || 0;
|
||
const enc = document.getElementById('ram-enc').value;
|
||
const delMode = document.getElementById('ram-del-mode').value;
|
||
const gsMode = document.getElementById('ram-gs-mode').value;
|
||
const tagHex = (document.getElementById('ram-tag').value || '5F50').replace(/[^0-9a-fA-F]/g, '').padStart(4, '0').slice(0, 4);
|
||
const ssMode = parseInt(document.getElementById('ram-ss-mode').value, 16);
|
||
const ssState = parseInt(document.getElementById('ram-ss-state').value, 16);
|
||
|
||
const resultEl = document.getElementById('ram-result');
|
||
|
||
const INSTALL_P1 = {
|
||
'install-load': 0x02,
|
||
'install-install': 0x0C,
|
||
'install-make-sel': 0x08,
|
||
'install-reg-update': 0x40,
|
||
'install-extradition': 0x10,
|
||
};
|
||
|
||
function lv(data) {
|
||
if (!data) return '00';
|
||
return berLenStr(data.length / 2) + data;
|
||
}
|
||
|
||
function computePriv() {
|
||
const privHex = document.getElementById('ram-priv').value;
|
||
const b1 = parseInt(privHex.substr(0, 2), 16) || 0;
|
||
const b2 = privHex.length >= 4 ? parseInt(privHex.substr(2, 2), 16) : 0;
|
||
const b3 = privHex.length >= 6 ? parseInt(privHex.substr(4, 2), 16) : 0;
|
||
if (b2 === 0 && b3 === 0) return b1.toString(16).padStart(2, '0').toUpperCase();
|
||
return b1.toString(16).padStart(2, '0').toUpperCase() + b2.toString(16).padStart(2, '0').toUpperCase() + b3.toString(16).padStart(2, '0').toUpperCase();
|
||
}
|
||
|
||
function buildToolkitParams() {
|
||
if (!document.getElementById('ram-toolkit-enable').checked) return '';
|
||
const tkMode = document.getElementById('ram-tk-mode').value;
|
||
const priority = parseInt(document.getElementById('ram-tk-priority').value) || 0;
|
||
const timers = parseInt(document.getElementById('ram-tk-timers').value) || 0;
|
||
const textLen = parseInt(document.getElementById('ram-tk-textlen').value) || 0;
|
||
const menus = parseInt(document.getElementById('ram-tk-menus').value) || 0;
|
||
const firstPos = parseInt(document.getElementById('ram-tk-firstpos').value) || 0;
|
||
const firstId = (document.getElementById('ram-tk-firstid').value || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2) || '00';
|
||
const lastPos = parseInt(document.getElementById('ram-tk-lastpos').value) || 0;
|
||
const lastId = (document.getElementById('ram-tk-lastid').value || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2) || '00';
|
||
const channels = parseInt(document.getElementById('ram-tk-channels').value) || 0;
|
||
const msl = document.getElementById('ram-tk-msl').value;
|
||
const tar = (document.getElementById('ram-tk-tar').value || '').replace(/[^0-9a-fA-F]/g, '');
|
||
const adRaw = (document.getElementById('ram-tk-ad').value || '').replace(/[^0-9a-fA-F]/g, '');
|
||
const ad = adRaw.padStart(2, '0').slice(0, 2);
|
||
const services = parseInt(document.getElementById('ram-tk-services').value) || 0;
|
||
|
||
if (tar && tar.length % 6 !== 0) { resultEl.value = 'Error: TAR must be 3-byte multiples'; return null; }
|
||
if (parseInt(firstId, 16) > 0x7F || parseInt(lastId, 16) > 0x7F) { resultEl.value = 'Error: menu IDs must be <= 7F'; return null; }
|
||
|
||
let menuPairs = '';
|
||
for (let i = 1; i <= menus; i++) {
|
||
if (i === 1) menuPairs += firstPos.toString(16).padStart(2, '0') + firstId;
|
||
else if (i === menus) menuPairs += lastPos.toString(16).padStart(2, '0') + lastId;
|
||
else menuPairs += '0000';
|
||
}
|
||
|
||
const mslField = msl === '00' ? '00' : '0201' + msl;
|
||
|
||
let tkPayload = '';
|
||
if (tkMode === 'ca') {
|
||
tkPayload = (adRaw ? '01' + ad : '00') +
|
||
priority.toString(16).padStart(2, '0') +
|
||
timers.toString(16).padStart(2, '0') +
|
||
textLen.toString(16).padStart(2, '0') +
|
||
menus.toString(16).padStart(2, '0') +
|
||
menuPairs +
|
||
channels.toString(16).padStart(2, '0') +
|
||
mslField +
|
||
(tar ? (tar.length / 2).toString(16).padStart(2, '0') + tar : '00');
|
||
const caTlv = 'CA' + berLenStr(tkPayload.length / 2) + tkPayload;
|
||
return 'EF' + berLenStr(caTlv.length / 2) + caTlv;
|
||
} else {
|
||
tkPayload = priority.toString(16).padStart(2, '0') +
|
||
timers.toString(16).padStart(2, '0') +
|
||
textLen.toString(16).padStart(2, '0') +
|
||
menus.toString(16).padStart(2, '0') +
|
||
menuPairs +
|
||
channels.toString(16).padStart(2, '0') +
|
||
mslField +
|
||
(tar ? (tar.length / 2).toString(16).padStart(2, '0') + tar : '00') +
|
||
services.toString(16).padStart(2, '0');
|
||
const innerTlv = '80' + berLenStr(tkPayload.length / 2) + tkPayload;
|
||
return 'EA' + berLenStr(innerTlv.length / 2) + innerTlv;
|
||
}
|
||
}
|
||
|
||
let apdu = '';
|
||
if (cmd === 'install-load') {
|
||
if (!aid) { resultEl.value = 'Error: specify Load File AID'; return; }
|
||
let dataField = lv(aid) + lv(sdAid) + '00' + (loadData ? lv(loadData) : '00') + '00';
|
||
const dataLen = berLenStr(dataField.length / 2);
|
||
apdu = buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dataLen, 16), dataField) + '00';
|
||
} else if (cmd === 'install-install') {
|
||
if (!aid) { resultEl.value = 'Error: specify AID'; return; }
|
||
const privBytes = computePriv();
|
||
let installParams = 'C900';
|
||
const tkParams = buildToolkitParams();
|
||
if (tkParams === null) return;
|
||
if (tkParams) installParams += tkParams;
|
||
let dataField = lv(elfAid) + lv(modAid) + lv(aid) + lv(privBytes) + lv(installParams) + '00';
|
||
const dataLen = berLenStr(dataField.length / 2);
|
||
apdu = buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dataLen, 16), dataField) + '00';
|
||
} else if (cmd === 'install-make-sel') {
|
||
if (!aid) { resultEl.value = 'Error: specify AID'; return; }
|
||
const privBytes = computePriv();
|
||
let dataField = '00' + '00' + lv(aid) + lv(privBytes) + '00' + '00';
|
||
const dataLen = berLenStr(dataField.length / 2);
|
||
apdu = buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dataLen, 16), dataField) + '00';
|
||
} else if (cmd === 'install-reg-update') {
|
||
if (!aid) { resultEl.value = 'Error: specify AID'; return; }
|
||
const privBytes = computePriv();
|
||
let dataField = lv(sdAid) + '00' + lv(aid) + lv(privBytes) + '00' + '00';
|
||
const dataLen = berLenStr(dataField.length / 2);
|
||
apdu = buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dataLen, 16), dataField) + '00';
|
||
} else if (cmd === 'install-extradition') {
|
||
if (!sdAid || sdAid.length < 10 || sdAid.length > 20) { resultEl.value = 'Error: specify SD AID (5-10 bytes)'; return; }
|
||
if (!aid) { resultEl.value = 'Error: specify AID'; return; }
|
||
let dataField = lv(sdAid) + '00' + lv(aid) + '00' + '00' + '00';
|
||
const dataLen = berLenStr(dataField.length / 2);
|
||
apdu = buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dataLen, 16), dataField) + '00';
|
||
} else if (cmd === 'load') {
|
||
if (!ramData) { resultEl.value = 'Error: specify data'; return; }
|
||
const dataLen = berLenStr(ramData.length / 2);
|
||
apdu = buildApdu(0x80, 0xE8, 0x80, blockNum, parseInt(dataLen, 16), ramData) + '00';
|
||
} else if (cmd === 'delete') {
|
||
if (!aid) { resultEl.value = 'Error: specify AID'; return; }
|
||
const dataField = '4F' + berLenStr(aid.length / 2) + aid;
|
||
const dataLen = berLenStr(dataField.length / 2);
|
||
apdu = buildApdu(0x80, 0xE4, 0x00, parseInt(delMode, 16), parseInt(dataLen, 16), dataField) + '00';
|
||
} else if (cmd === 'get-status') {
|
||
const gsP2 = document.getElementById('ram-gs-p2').value;
|
||
apdu = buildApdu(0x80, 0xF2, parseInt(gsMode, 16), parseInt(gsP2, 16), 0x02, '4F00') + '00';
|
||
} else if (cmd === 'get-data') {
|
||
const tag = parseInt(tagHex, 16);
|
||
if (tagHex === '2F00' || tagHex === '5031') {
|
||
apdu = buildApdu(0x80, 0xCA, (tag >> 8) & 0xFF, tag & 0xFF, 0x02, '5C00') + '00';
|
||
} else {
|
||
apdu = buildApdu(0x80, 0xCA, (tag >> 8) & 0xFF, tag & 0xFF, 0x00, null) + '00';
|
||
}
|
||
} else if (cmd === 'store-data') {
|
||
if (!ramData) { resultEl.value = 'Error: specify data'; return; }
|
||
const dataLen = berLenStr(ramData.length / 2);
|
||
apdu = buildApdu(0x80, 0xE2, parseInt(enc, 16), blockNum, parseInt(dataLen, 16), ramData);
|
||
} else if (cmd === 'set-status') {
|
||
if (ssMode === 0x80) {
|
||
apdu = buildApdu(0x80, 0xF0, 0x80, ssState, 0x00, null);
|
||
} else {
|
||
if (!aid) { resultEl.value = 'Error: specify AID'; return; }
|
||
apdu = buildApdu(0x80, 0xF0, ssMode, ssState, aid.length / 2, aid);
|
||
}
|
||
} else if (cmd === 'ext-auth') {
|
||
if (!ramData || ramData.length !== 32) { resultEl.value = 'Error: specify 16-byte data (host cryptogram + MAC)'; return; }
|
||
const authP1 = parseInt(document.getElementById('ram-authp1').value, 16);
|
||
apdu = buildApdu(0x84, 0x82, authP1, 0x00, 0x10, ramData);
|
||
} else if (cmd === 'int-auth') {
|
||
if (!ramData) { resultEl.value = 'Error: specify data (challenge)'; return; }
|
||
const dataLen = berLenStr(ramData.length / 2);
|
||
apdu = buildApdu(0x00, 0x88, 0x00, 0x00, parseInt(dataLen, 16), ramData) + '00';
|
||
}
|
||
|
||
resultEl.value = apdu || 'Error: unknown command';
|
||
if (apdu) document.getElementById('ram-pack-btn').disabled = false;
|
||
}
|
||
|
||
function updateConvFields() {
|
||
const type = document.getElementById('conv-type').value;
|
||
const isPlmn = type === 'plmn' || type === 'plmnwact';
|
||
document.getElementById('conv-input').style.display = isPlmn ? 'none' : '';
|
||
document.getElementById('conv-plmn-fields').style.display = isPlmn ? '' : 'none';
|
||
document.getElementById('conv-act-row').style.display = type === 'plmnwact' ? '' : 'none';
|
||
}
|
||
|
||
function encPlmn(mcc, mnc) {
|
||
mcc = mcc.padEnd(3, '0').slice(0, 3);
|
||
mnc = mnc.padEnd(3, 'F').slice(0, 3);
|
||
const d = mcc + mnc;
|
||
const b1 = d[1] + d[0];
|
||
const b2 = d[5] + d[2];
|
||
const b3 = d[4] + d[3];
|
||
return (b1 + b2 + b3).toUpperCase();
|
||
}
|
||
|
||
function genConvert() {
|
||
const type = document.getElementById('conv-type').value;
|
||
const input = document.getElementById('conv-input').value.trim();
|
||
const mcc = document.getElementById('conv-mcc').value.trim();
|
||
const mnc = document.getElementById('conv-mnc').value.trim();
|
||
const act = document.getElementById('conv-act').value;
|
||
|
||
let result = '';
|
||
try {
|
||
if (type === 'imsi') {
|
||
if (!input) { result = 'Error: enter a value'; }
|
||
else if (!/^\d{15}$/.test(input)) { result = 'Error: IMSI must be 15 digits'; }
|
||
else result = encImsi(input);
|
||
} else if (type === 'msisdn') {
|
||
if (!input) { result = 'Error: enter a value'; }
|
||
else result = encMsisdn(input);
|
||
} else if (type === 'iccid') {
|
||
if (!input) { result = 'Error: enter a value'; }
|
||
else result = encIccid(input);
|
||
} else if (type === 'spn') {
|
||
if (!input) { result = 'Error: enter a value'; }
|
||
else result = encodeSpn(input);
|
||
} else if (type === 'plmn') {
|
||
if (!mcc || !mnc) { result = 'Error: specify MCC and MNC'; }
|
||
else if (!/^\d{3}$/.test(mcc)) { result = 'Error: MCC must be 3 digits'; }
|
||
else if (!/^\d{2,3}$/.test(mnc)) { result = 'Error: MNC must be 2-3 digits'; }
|
||
else result = encPlmn(mcc, mnc);
|
||
} else if (type === 'plmnwact') {
|
||
if (!mcc || !mnc) { result = 'Error: specify MCC and MNC'; }
|
||
else if (!/^\d{3}$/.test(mcc)) { result = 'Error: MCC must be 3 digits'; }
|
||
else if (!/^\d{2,3}$/.test(mnc)) { result = 'Error: MNC must be 2-3 digits'; }
|
||
else result = encPlmn(mcc, mnc) + act.toUpperCase();
|
||
} else if (type === 'nibswap') {
|
||
if (!input) { result = 'Error: enter a value'; }
|
||
else if (input.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(input)) { result = 'Error: enter an even number of hex characters'; }
|
||
else result = swapNibbles(input);
|
||
}
|
||
} catch (e) {
|
||
result = 'Error: ' + e.message;
|
||
}
|
||
document.getElementById('conv-result').value = result;
|
||
}
|
||
|
||
// ===== Secured Packet UI =====
|
||
function packToSp(sourceId) {
|
||
const val = document.getElementById(sourceId).value;
|
||
if (!val) return;
|
||
switchTab('sp');
|
||
document.getElementById('sp-apdu').value = val;
|
||
}
|
||
function spCntrAdjust(delta) {
|
||
const el = document.getElementById('sp-cntr');
|
||
let val = parseInt(el.value.replace(/[^0-9a-fA-F]/g, ''), 16) || 0;
|
||
val = Math.max(0, val + delta);
|
||
el.value = val.toString(16).padStart(10, '0').toUpperCase();
|
||
}
|
||
function updateSp() {
|
||
document.getElementById('sp-spi1-hex').value = document.getElementById('sp-spi1').value;
|
||
const spi2Base = parseInt(document.getElementById('sp-spi2').value, 16);
|
||
const sm = parseInt(document.getElementById('sp-spi2-sm').value, 16);
|
||
const spi2Byte = spi2Base | (sm << 5);
|
||
document.getElementById('sp-spi2-hex').value = spi2Byte.toString(16).padStart(2, '0').toUpperCase();
|
||
}
|
||
function updateSpKic() {
|
||
const idx = parseInt(document.getElementById('sp-kic-idx').value, 16);
|
||
const alg = parseInt(document.getElementById('sp-kic-alg').value, 16);
|
||
const byte = (idx << 4) | alg;
|
||
document.getElementById('sp-kic-hex').value = byte.toString(16).padStart(2, '0').toUpperCase();
|
||
updateKeyPlaceholder('kic', alg & 0x0F);
|
||
}
|
||
function updateSpKid() {
|
||
const idx = parseInt(document.getElementById('sp-kid-idx').value, 16);
|
||
const alg = parseInt(document.getElementById('sp-kid-alg').value, 16);
|
||
const byte = (idx << 4) | alg;
|
||
document.getElementById('sp-kid-hex').value = byte.toString(16).padStart(2, '0').toUpperCase();
|
||
updateKeyPlaceholder('kid', alg & 0x0F);
|
||
}
|
||
function updateKeyPlaceholder(prefix, algNib) {
|
||
const keyLen = {1: 8, 2: 16, 5: 16, 9: 24, 0xD: 8}[algNib] || 8;
|
||
const ph = algNib === 2 ? '16/24/32 bytes key (AES)' : keyLen + ' bytes key';
|
||
document.getElementById('sp-' + prefix + '-key').placeholder = ph;
|
||
}
|
||
|
||
// ===== DES / 3DES using des.js library (bundled as des-bundle.js) =====
|
||
function hexToBytes(s) {
|
||
const len = s.length / 2;
|
||
const b = new Uint8Array(len);
|
||
for (let i = 0; i < len; i++) b[i] = parseInt(s.substr(i * 2, 2), 16);
|
||
return b;
|
||
}
|
||
|
||
function bytesToHex(b) {
|
||
return Array.from(b).map(x => x.toString(16).padStart(2, '0').toUpperCase()).join('');
|
||
}
|
||
|
||
function des3Keys(keyBytes) {
|
||
const kLen = keyBytes.length;
|
||
if (kLen === 8) return [keyBytes, keyBytes, keyBytes];
|
||
if (kLen === 16) return [keyBytes.subarray(0,8), keyBytes.subarray(8,16), keyBytes.subarray(0,8)];
|
||
if (kLen === 24) return [keyBytes.subarray(0,8), keyBytes.subarray(8,16), keyBytes.subarray(16,24)];
|
||
throw new Error('Invalid key length: ' + kLen + ' bytes (expected 8, 16, or 24)');
|
||
}
|
||
|
||
function des3EncryptBlock(block, keyBytes) {
|
||
const [k1, k2, k3] = des3Keys(keyBytes);
|
||
const c1 = des.DES.create({type: 'encrypt', key: k1});
|
||
const c2 = des.DES.create({type: 'decrypt', key: k2});
|
||
const c3 = des.DES.create({type: 'encrypt', key: k3});
|
||
const out = new Uint8Array(8);
|
||
c1._update(block, 0, out, 0);
|
||
c2._update(out, 0, out, 0);
|
||
c3._update(out, 0, out, 0);
|
||
return out;
|
||
}
|
||
|
||
function des3DecryptBlock(block, keyBytes) {
|
||
const [k1, k2, k3] = des3Keys(keyBytes);
|
||
const c1 = des.DES.create({type: 'decrypt', key: k3});
|
||
const c2 = des.DES.create({type: 'encrypt', key: k2});
|
||
const c3 = des.DES.create({type: 'decrypt', key: k1});
|
||
const out = new Uint8Array(8);
|
||
c1._update(block, 0, out, 0);
|
||
c2._update(out, 0, out, 0);
|
||
c3._update(out, 0, out, 0);
|
||
return out;
|
||
}
|
||
|
||
function desEncryptBlock(block, key) {
|
||
const c = des.DES.create({type: 'encrypt', key: key});
|
||
const out = new Uint8Array(8);
|
||
c._update(block, 0, out, 0);
|
||
return out;
|
||
}
|
||
|
||
function desDecryptBlock(block, key) {
|
||
const c = des.DES.create({type: 'decrypt', key: key});
|
||
const out = new Uint8Array(8);
|
||
c._update(block, 0, out, 0);
|
||
return out;
|
||
}
|
||
|
||
function des3CbcEncrypt(data, keyBytes, iv) {
|
||
const bs = 8;
|
||
const padded = zeroPad(data, bs);
|
||
const out = new Uint8Array(padded.length);
|
||
let prev = iv;
|
||
for (let i = 0; i < padded.length; i += bs) {
|
||
const block = padded.subarray(i, i + bs);
|
||
const xored = new Uint8Array(bs);
|
||
for (let j = 0; j < bs; j++) xored[j] = block[j] ^ prev[j];
|
||
const enc = des3EncryptBlock(xored, keyBytes);
|
||
out.set(enc, i);
|
||
prev = enc;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function xorBytes(a, b) {
|
||
const len = Math.min(a.length, b.length);
|
||
const out = new Uint8Array(len);
|
||
for (let i = 0; i < len; i++) out[i] = a[i] ^ b[i];
|
||
return out;
|
||
}
|
||
|
||
function zeroPad(data, blockSize) {
|
||
const padLen = blockSize - (data.length % blockSize);
|
||
if (padLen === blockSize) return data;
|
||
const out = new Uint8Array(data.length + padLen);
|
||
out.set(data);
|
||
for (let i = data.length; i < out.length; i++) out[i] = 0x00;
|
||
return out;
|
||
}
|
||
|
||
// TS 03.48 / TS 102 225 KID CC: DES/3DES in CBC mode, zero IV, '00' padding.
|
||
// The MAC is the last 8 octets of the CBC encryption of the input.
|
||
function cbcMac(data, keyBytes) {
|
||
const bs = 8;
|
||
const padded = zeroPad(data, bs);
|
||
let prev = new Uint8Array(bs);
|
||
for (let i = 0; i < padded.length; i += bs) {
|
||
const block = padded.subarray(i, i + bs);
|
||
const xored = xorBytes(block, prev);
|
||
prev = des3EncryptBlock(xored, keyBytes);
|
||
}
|
||
return prev;
|
||
}
|
||
|
||
// ===== AES using aes-js library (bundled as aes-bundle.js) =====
|
||
function aesCbcEncrypt(data, keyBytes, iv) {
|
||
const bs = 16;
|
||
const padded = zeroPad(data, bs);
|
||
const aesCbc = new aesjs.ModeOfOperation.cbc(keyBytes, iv);
|
||
return new Uint8Array(aesCbc.encrypt(padded));
|
||
}
|
||
|
||
function aesShiftLeft1(bytes) {
|
||
const out = new Uint8Array(bytes.length);
|
||
let carry = 0;
|
||
for (let i = bytes.length - 1; i >= 0; i--) {
|
||
out[i] = (bytes[i] << 1) | carry;
|
||
carry = (bytes[i] & 0x80) ? 1 : 0;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function aesCmacSubkeys(keyBytes) {
|
||
const ecb = new aesjs.ModeOfOperation.ecb(keyBytes);
|
||
const L = new Uint8Array(ecb.encrypt(new Uint8Array(16)));
|
||
const K1 = aesShiftLeft1(L);
|
||
if (L[0] & 0x80) K1[15] ^= 0x87;
|
||
const K2 = aesShiftLeft1(K1);
|
||
if (K1[0] & 0x80) K2[15] ^= 0x87;
|
||
return [K1, K2];
|
||
}
|
||
|
||
// AES-CMAC (NIST SP 800-38B) truncated to 8 octets, matching pySim's CMAC(mac_len=8).
|
||
// Unlike the 3DES CBC-MAC above, CMAC pads internally - do NOT pre-pad the input.
|
||
function aesCmac(data, keyBytes) {
|
||
const bs = 16;
|
||
const [K1, K2] = aesCmacSubkeys(keyBytes);
|
||
const ecb = new aesjs.ModeOfOperation.ecb(keyBytes);
|
||
const n = Math.max(1, Math.ceil(data.length / bs));
|
||
const lastLen = data.length % bs;
|
||
const complete = data.length > 0 && lastLen === 0;
|
||
let prev = new Uint8Array(bs);
|
||
for (let i = 0; i < n - 1; i++) {
|
||
const block = data.subarray(i * bs, (i + 1) * bs);
|
||
const xored = xorBytes(block, prev);
|
||
prev = new Uint8Array(ecb.encrypt(xored));
|
||
}
|
||
let lastBlock;
|
||
if (complete) {
|
||
lastBlock = xorBytes(data.subarray((n - 1) * bs, n * bs), K1);
|
||
} else {
|
||
const padded = new Uint8Array(bs);
|
||
const start = (n - 1) * bs;
|
||
if (data.length > start) padded.set(data.subarray(start));
|
||
padded[data.length - start] = 0x80;
|
||
lastBlock = xorBytes(padded, K2);
|
||
}
|
||
const xored = xorBytes(lastBlock, prev);
|
||
const mac = new Uint8Array(ecb.encrypt(xored));
|
||
return mac.subarray(0, 8);
|
||
}
|
||
|
||
// ===== Secure Packet assembly =====
|
||
// TS 102 225 Table 1/2 + TS 31.115 Table 1 (SMS-PP):
|
||
// CPL(2) CHL(1) SPI(2) KIc(1) KID(1) TAR(3) CNTR(5) PCNTR(1) [RC/CC/DS] secured-data(+padding)
|
||
// CPL = octets from CHL to end incl. padding; CHL = 13 + len_sig.
|
||
// CPI (0x70) and CHI (null) are NOT part of the SMS packet data.
|
||
function genSp() {
|
||
const apduHex = (document.getElementById('sp-apdu').value || '').replace(/[^0-9a-fA-F]/g, '');
|
||
if (!apduHex) { document.getElementById('sp-result').value = 'Error: specify APDU'; return; }
|
||
|
||
const spi1 = parseInt(document.getElementById('sp-spi1').value, 16);
|
||
const spi2 = parseInt(document.getElementById('sp-spi2-hex').value, 16);
|
||
const kicHex = document.getElementById('sp-kic-hex').value;
|
||
const kidHex = document.getElementById('sp-kid-hex').value;
|
||
const tarHex = (document.getElementById('sp-tar').value || '').replace(/[^0-9a-fA-F]/g, '').padEnd(6, '0').slice(0, 6);
|
||
const cntrHex = (document.getElementById('sp-cntr').value || '').replace(/[^0-9a-fA-F]/g, '').padStart(10, '0').slice(0, 10);
|
||
const kicKeyHex = (document.getElementById('sp-kic-key').value || '').replace(/[^0-9a-fA-F]/g, '');
|
||
const kidKeyHex = (document.getElementById('sp-kid-key').value || '').replace(/[^0-9a-fA-F]/g, '');
|
||
const padByte = parseInt(document.getElementById('sp-padding').value, 16);
|
||
|
||
const kicAlg = parseInt(kicHex, 16) & 0x0F;
|
||
const kidAlg = parseInt(kidHex, 16) & 0x0F;
|
||
const kicIsAes = kicAlg === 0x02;
|
||
const kidIsAes = kidAlg === 0x02;
|
||
|
||
const ciphering = (spi1 & 0x04) !== 0;
|
||
const hasMac = (spi1 & 0x03) === 0x02;
|
||
|
||
const resultEl = document.getElementById('sp-result');
|
||
|
||
// Rel-18: AES requires a replay-protected counter (SPI1 b5b4 = 10 or 11)
|
||
if ((kicIsAes && ciphering) || (kidIsAes && hasMac)) {
|
||
const counterBits = (spi1 >> 3) & 0x03;
|
||
if (counterBits !== 2 && counterBits !== 3) {
|
||
resultEl.value = 'Error: AES requires a replay-protected counter (SPI1 counter: "higher" or "+1")';
|
||
return;
|
||
}
|
||
}
|
||
|
||
let kicKey, kidKey;
|
||
try {
|
||
if (kicKeyHex) {
|
||
kicKey = hexToBytes(kicKeyHex);
|
||
const ok = kicIsAes
|
||
? (kicKey.length === 16 || kicKey.length === 24 || kicKey.length === 32)
|
||
: (kicKey.length === 8 || kicKey.length === 16 || kicKey.length === 24);
|
||
if (!ok) {
|
||
resultEl.value = kicIsAes
|
||
? 'Error: AES KIc key must be 16, 24, or 32 bytes'
|
||
: 'Error: KIc key must be 8, 16, or 24 bytes';
|
||
return;
|
||
}
|
||
}
|
||
} catch (e) { resultEl.value = 'KIc key error: ' + e.message; return; }
|
||
|
||
try {
|
||
if (kidKeyHex) {
|
||
kidKey = hexToBytes(kidKeyHex);
|
||
const ok = kidIsAes
|
||
? (kidKey.length === 16 || kidKey.length === 24 || kidKey.length === 32)
|
||
: (kidKey.length === 8 || kidKey.length === 16 || kidKey.length === 24);
|
||
if (!ok) {
|
||
resultEl.value = kidIsAes
|
||
? 'Error: AES KID key must be 16, 24, or 32 bytes'
|
||
: 'Error: KID key must be 8, 16, or 24 bytes';
|
||
return;
|
||
}
|
||
}
|
||
} catch (e) { resultEl.value = 'KID key error: ' + e.message; return; }
|
||
|
||
if (ciphering && !kicKey) { resultEl.value = 'Error: ciphering requires KIc key'; return; }
|
||
if (hasMac && !kidKey) { resultEl.value = 'Error: MAC requires KID key'; return; }
|
||
|
||
const apdu = hexToBytes(apduHex);
|
||
|
||
const macLen = hasMac ? 8 : 0;
|
||
// padding aligns CNTR+PCNTR+RC/CC/DS+data to the cipher block size (ciphering only)
|
||
const cipherBlock = kicIsAes ? 16 : 8;
|
||
const padCnt = ciphering ? (cipherBlock - ((6 + macLen + apdu.length) % cipherBlock)) % cipherBlock : 0;
|
||
|
||
const chl = 13 + macLen;
|
||
const cpl = 14 + macLen + apdu.length + padCnt;
|
||
|
||
const packetLen = 3 + chl + apdu.length + padCnt;
|
||
const packet = new Uint8Array(packetLen);
|
||
|
||
packet[0] = (cpl >> 8) & 0xFF;
|
||
packet[1] = cpl & 0xFF;
|
||
packet[2] = chl;
|
||
packet[3] = spi1;
|
||
packet[4] = spi2;
|
||
packet[5] = parseInt(kicHex, 16);
|
||
packet[6] = parseInt(kidHex, 16);
|
||
packet[7] = parseInt(tarHex.substr(0,2), 16);
|
||
packet[8] = parseInt(tarHex.substr(2,2), 16);
|
||
packet[9] = parseInt(tarHex.substr(4,2), 16);
|
||
packet[10] = parseInt(cntrHex.substr(0,2), 16);
|
||
packet[11] = parseInt(cntrHex.substr(2,2), 16);
|
||
packet[12] = parseInt(cntrHex.substr(4,2), 16);
|
||
packet[13] = parseInt(cntrHex.substr(6,2), 16);
|
||
packet[14] = parseInt(cntrHex.substr(8,2), 16);
|
||
packet[15] = padCnt;
|
||
const macOff = 16;
|
||
const dataOff = macOff + macLen;
|
||
packet.set(apdu, dataOff);
|
||
for (let i = 0; i < padCnt; i++) packet[dataOff + apdu.length + i] = padByte;
|
||
|
||
if (hasMac && kidKey) {
|
||
// CC is computed over CPL+CHL+SPI+KIc+KID+TAR+CNTR+PCNTR+data(+padding),
|
||
// i.e. everything up to (but excluding) the RC/CC/DS field (TS 31.115 4.2).
|
||
const macInput = new Uint8Array(packet.length - macLen);
|
||
macInput.set(packet.subarray(0, macOff));
|
||
macInput.set(packet.subarray(dataOff), macOff);
|
||
const mac = kidIsAes ? aesCmac(macInput, kidKey) : cbcMac(macInput, kidKey);
|
||
packet.set(mac, macOff);
|
||
}
|
||
|
||
if (ciphering && kicKey) {
|
||
// ciphering covers CNTR+PCNTR+RC/CC/DS+secured data (TS 102 225 Table 2 note 1)
|
||
const toEncrypt = packet.subarray(10, packetLen);
|
||
const encrypted = kicIsAes
|
||
? aesCbcEncrypt(toEncrypt, kicKey, new Uint8Array(16))
|
||
: des3CbcEncrypt(toEncrypt, kicKey, new Uint8Array(8));
|
||
packet.set(encrypted, 10);
|
||
}
|
||
|
||
resultEl.value = bytesToHex(packet);
|
||
}
|
||
|
||
// ===== BER-TLV =====
|
||
const BER_TAGS = {'c-apdu':'22','immediate':'81','error':'82','chaining':'83'};
|
||
const BER_QUAL = {
|
||
refresh: [
|
||
['00','NAA Initialization and Full File Change Notification'],
|
||
['01','File Change Notification'],
|
||
['02','NAA Initialization and File Change Notification'],
|
||
['03','NAA Initialization'],
|
||
['04','UICC Reset'],
|
||
['05','NAA Application Reset (not for 2G SIM)'],
|
||
['06','NAA Session Reset (not for 2G SIM)'],
|
||
['07','Steering of Roaming'],
|
||
['08','Steering of Roaming for I-WLAN'],
|
||
['09','eUICC Profile State Change (eUICC only)'],
|
||
['0A','Application Update (eUICC only)']
|
||
],
|
||
display: [
|
||
['00','Normal priority'],
|
||
['01','High priority'],
|
||
['80','Wait for user to clear message']
|
||
],
|
||
tone: [
|
||
['00','Use of vibrate alert up to the terminal'],
|
||
['01','Vibrate alert, if available, with the tone']
|
||
]
|
||
};
|
||
const BER_TONES = [
|
||
['01','Dial tone'],['02','Called subscriber busy'],['03','Congestion'],
|
||
['04','Radio path acknowledge'],['05','Radio path not available'],
|
||
['06','Error/Special info'],['07','Call waiting'],['08','Ringing tone'],
|
||
['10','General beep'],['11','Positive acknowledgement'],['12','Negative acknowledgement']
|
||
];
|
||
const BER_DEVICES = [['81','UICC'],['82','Terminal'],['83','Network'],['02','Display']];
|
||
|
||
function addBerRow(type) {
|
||
const c = document.getElementById('ber-rows');
|
||
const idx = c.children.length;
|
||
const row = document.createElement('div');
|
||
row.className = 'ber-row border border-gray-300 dark:border-slate-600 rounded p-2 bg-gray-50 dark:bg-slate-800';
|
||
row.dataset.berIdx = idx;
|
||
row.innerHTML = berRowHtml(idx, type);
|
||
c.appendChild(row);
|
||
onBerTypeChange(row.querySelector('.ber-type'));
|
||
}
|
||
|
||
function berRowHtml(idx, type) {
|
||
const types = ['c-apdu','immediate','error','chaining'].map(t =>
|
||
`<option value="${t}"${t===type?' selected':''}>${t==='c-apdu'?'C-APDU':t==='immediate'?'Immediate':t==='error'?'Error':'Chaining'}</option>`
|
||
).join('');
|
||
return `<div class="flex gap-2 items-start mb-1">
|
||
<span class="text-xs text-gray-500 dark:text-slate-400 w-6 pt-2">${idx+1}</span>
|
||
<select class="ber-type text-xs w-24 border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300" onchange="onBerTypeChange(this)">
|
||
${types}
|
||
</select>
|
||
<div class="ber-body flex-1"></div>
|
||
<button onclick="removeBerRow(this)" class="text-red-500 hover:text-red-700 text-sm px-1 pt-2">✕</button>
|
||
</div>
|
||
<div class="ber-preview text-xs text-gray-400 dark:text-slate-500 font-mono ml-8" data-ber-preview="${idx}"></div>`;
|
||
}
|
||
|
||
function removeBerRow(btn) {
|
||
btn.closest('.ber-row').remove();
|
||
document.querySelectorAll('.ber-row').forEach((r,i)=>r.dataset.berIdx=i);
|
||
document.querySelectorAll('.ber-row .ber-preview').forEach((p,i)=>p.dataset.berPreview=i);
|
||
}
|
||
|
||
function onBerTypeChange(sel) {
|
||
const row = sel.closest('.ber-row');
|
||
const body = row.querySelector('.ber-body');
|
||
const type = sel.value;
|
||
if (type === 'c-apdu') {
|
||
body.innerHTML = `<input class="ber-hex w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300" placeholder="hex" oninput="updateBerRow(this)">`;
|
||
} else if (type === 'chaining') {
|
||
body.innerHTML = buildScriptChainingRow(row.dataset.berIdx);
|
||
} else if (type === 'error') {
|
||
body.innerHTML = buildErrorActionRow();
|
||
const actionType = body.querySelector('.error-action-type');
|
||
onErrorActionTypeChange(actionType);
|
||
updateBerRow(sel);
|
||
} else {
|
||
body.innerHTML = `<div class="flex gap-1 mb-1 flex-wrap">
|
||
<select class="ber-subtype text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300" onchange="onBerSubTypeChange(this)">
|
||
<option value="action">Action indicator</option>
|
||
<option value="proactive">Proactive command</option>
|
||
<option value="custom">Custom hex</option>
|
||
</select>
|
||
<span class="ber-sub-body flex-1"></span>
|
||
</div>`;
|
||
onBerSubTypeChange(body.querySelector('.ber-subtype'));
|
||
}
|
||
updateBerRow(sel);
|
||
}
|
||
|
||
function buildErrorActionRow() {
|
||
return `<div class="flex gap-2 items-center">
|
||
<span class="text-xs text-gray-600 dark:text-slate-400">Error Action:</span>
|
||
<select class="error-action-type text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-700 dark:text-slate-300" onchange="onErrorActionTypeChange(this)">
|
||
<option value="proactive" selected>Proactive command (DISPLAY TEXT / PLAY TONE)</option>
|
||
<option value="noaction">No action</option>
|
||
<option value="reference">Reference to EFRMA record</option>
|
||
<option value="custom">Custom hex</option>
|
||
</select>
|
||
</div>
|
||
<div class="error-action-body mt-2"></div>`;
|
||
}
|
||
|
||
function onErrorActionTypeChange(sel) {
|
||
const body = sel.parentElement.nextElementSibling;
|
||
if (sel.value === 'proactive') {
|
||
body.innerHTML = buildBerPcHtml(['display','tone']);
|
||
const pc = body.querySelector('.ber-pc');
|
||
berPopulateDevices(pc);
|
||
onBerPcTypeChange(pc.querySelector('.ber-pc-type'));
|
||
} else if (sel.value === 'noaction') {
|
||
body.innerHTML = '<span class="text-xs text-gray-400 dark:text-slate-500">Emits <code class="font-mono">82 00</code> (no action)</span>';
|
||
} else if (sel.value === 'reference') {
|
||
body.innerHTML = `<div class="flex gap-2 items-center">
|
||
<span class="text-xs text-gray-600 dark:text-slate-400">EFRMA record ref:</span>
|
||
<input class="error-ref font-mono text-xs w-10 border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300" maxlength="2" placeholder="01" oninput="updateBerRow(this)">
|
||
<span class="text-xs text-gray-400">(01-7F)</span>
|
||
</div>`;
|
||
} else {
|
||
body.innerHTML = `<input class="ber-hex w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300" placeholder="hex" oninput="updateBerRow(this)">`;
|
||
}
|
||
const row = sel.closest('.ber-row');
|
||
if (row) updateBerRow(sel);
|
||
}
|
||
|
||
function buildBerPcHtml(allowed = ['refresh','display','tone']) {
|
||
const typeOpts = {refresh:'REFRESH', display:'DISPLAY TEXT', tone:'PLAY TONE'};
|
||
return `<div class="ber-pc">
|
||
<div class="flex gap-1 mb-1 flex-wrap">
|
||
<select class="ber-pc-type text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300" onchange="onBerPcTypeChange(this)">
|
||
${allowed.map(t => `<option value="${t}">${typeOpts[t]}</option>`).join('')}
|
||
</select>
|
||
<span class="text-xs pt-1">#</span>
|
||
<input class="ber-pc-num text-xs w-10 border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" type="number" min="1" max="255" value="1" onchange="updateBerRow(this)">
|
||
<span class="text-xs pt-1">Qual:</span>
|
||
<select class="ber-pc-qual text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" onchange="updateBerRow(this)"></select>
|
||
</div>
|
||
<div class="flex gap-1 mb-1 flex-wrap items-center">
|
||
<span class="text-xs">IDs:</span>
|
||
<select class="ber-pc-src text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" onchange="updateBerRow(this)"></select>
|
||
<span class="text-xs">→</span>
|
||
<select class="ber-pc-dst text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" onchange="updateBerRow(this)"></select>
|
||
</div>
|
||
<div class="ber-pc-fields text-xs"></div>
|
||
</div>`;
|
||
}
|
||
|
||
function onBerSubTypeChange(sel) {
|
||
const body = sel.closest('.ber-sub-body') || sel.parentElement.querySelector('.ber-sub-body');
|
||
const st = sel.value;
|
||
|
||
if (st === 'action') {
|
||
body.innerHTML = `<select class="ber-action text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" onchange="updateBerRow(this)">
|
||
<option value="81">81 (Session indication)</option>
|
||
<option value="82">82 (Early response)</option>
|
||
</select>`;
|
||
} else if (st === 'custom') {
|
||
body.innerHTML = `<input class="ber-hex w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300" placeholder="hex" oninput="updateBerRow(this)">`;
|
||
} else {
|
||
body.innerHTML = buildBerPcHtml();
|
||
const pc = body.querySelector('.ber-pc');
|
||
berPopulateDevices(pc);
|
||
onBerPcTypeChange(pc.querySelector('.ber-pc-type'));
|
||
}
|
||
updateBerRow(sel);
|
||
}
|
||
|
||
function berPopulateDevices(pc) {
|
||
const src = pc.querySelector('.ber-pc-src');
|
||
const dst = pc.querySelector('.ber-pc-dst');
|
||
BER_DEVICES.forEach(([v,l])=>{
|
||
const o1 = document.createElement('option'); o1.value=v; o1.textContent=v+' ('+l+')';
|
||
if(v==='81') o1.selected=true; src.appendChild(o1);
|
||
const o2 = document.createElement('option'); o2.value=v; o2.textContent=v+' ('+l+')';
|
||
if(v==='82') o2.selected=true; dst.appendChild(o2);
|
||
});
|
||
}
|
||
|
||
function onBerPcTypeChange(sel) {
|
||
const pc = sel.closest('.ber-pc');
|
||
const qual = pc.querySelector('.ber-pc-qual');
|
||
const fields = pc.querySelector('.ber-pc-fields');
|
||
const type = sel.value;
|
||
qual.innerHTML = (BER_QUAL[type]||[['00','']]).map(([v,l])=>`<option value="${v}">${v} ${l}</option>`).join('');
|
||
if (type === 'display') {
|
||
fields.innerHTML = `<div class="flex gap-1 items-center">
|
||
<input class="ber-pc-text flex-1 text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300" placeholder="Text" oninput="updateBerRow(this)">
|
||
<select class="ber-pc-enc text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300" onchange="updateBerRow(this)">
|
||
<option value="gsm7">GSM 7-bit</option>
|
||
<option value="ucs2">UCS2</option>
|
||
</select>
|
||
</div>
|
||
<div class="flex gap-1 items-center mt-1">
|
||
<span class="text-xs">Alpha ID:</span>
|
||
<input class="ber-pc-alpha flex-1 text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" placeholder="hex (tag 85)" oninput="updateBerRow(this)">
|
||
</div>
|
||
<div class="flex gap-1 items-center mt-1">
|
||
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-slate-400"><input type="checkbox" class="ber-pc-dur-enable" checked onchange="updateBerRow(this)"> Duration:</label>
|
||
<select class="ber-pc-dur-unit text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" onchange="updateBerRow(this)">
|
||
<option value="00">minutes</option>
|
||
<option value="01" selected>seconds</option>
|
||
<option value="02">tenths of seconds</option>
|
||
</select>
|
||
<input class="ber-pc-dur-val text-xs w-12 border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" type="number" min="1" max="255" value="30" onchange="updateBerRow(this)">
|
||
</div>`;
|
||
} else if (type === 'tone') {
|
||
fields.innerHTML = `<div class="flex gap-1 items-center">
|
||
<span class="text-xs">Tone:</span>
|
||
<select class="ber-pc-tone text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" onchange="updateBerRow(this)"></select>
|
||
</div>`;
|
||
const tsel = fields.querySelector('.ber-pc-tone');
|
||
BER_TONES.forEach(([v,l])=>{const o=document.createElement('option');o.value=v;o.textContent=v+' '+l;tsel.appendChild(o);});
|
||
} else if (type === 'refresh') {
|
||
fields.innerHTML = `<div class="flex gap-1 items-center">
|
||
<span class="text-xs">File list (tag 92):</span>
|
||
<input class="ber-pc-flist flex-1 text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" placeholder="hex (optional)" oninput="updateBerRow(this)">
|
||
</div>`;
|
||
}
|
||
updateBerRow(sel);
|
||
}
|
||
|
||
function updateBerRow(el) {
|
||
const row = el.closest('.ber-row');
|
||
const preview = row.querySelector('.ber-preview');
|
||
const val = genBerRowValue(row);
|
||
preview.textContent = val || '(empty)';
|
||
}
|
||
|
||
function genBerRowValue(row) {
|
||
const type = row.querySelector('.ber-type').value;
|
||
const tag = BER_TAGS[type];
|
||
if (type === 'error') {
|
||
return genErrorActionValue(row, tag);
|
||
}
|
||
if (type === 'chaining') {
|
||
return genScriptChainingValue(row, tag);
|
||
}
|
||
if (type === 'c-apdu') {
|
||
const hex = (row.querySelector('.ber-hex').value||'').replace(/[^0-9a-fA-F]/g,'');
|
||
if (!hex) return '';
|
||
return tag + berLenStr(hex.length/2) + hex;
|
||
}
|
||
const st = row.querySelector('.ber-subtype').value;
|
||
if (st === 'action') {
|
||
const a = row.querySelector('.ber-action').value;
|
||
return tag + berLenStr(1) + a;
|
||
}
|
||
if (st === 'custom') {
|
||
const hex = (row.querySelector('.ber-hex').value||'').replace(/[^0-9a-fA-F]/g,'');
|
||
if (!hex) return '';
|
||
return tag + berLenStr(hex.length/2) + hex;
|
||
}
|
||
return genBerPcValue(row, tag);
|
||
}
|
||
|
||
function genErrorActionValue(row, tag) {
|
||
const actionType = row.querySelector('.error-action-type');
|
||
if (!actionType) return '';
|
||
const body = row.querySelector('.error-action-body');
|
||
if (!body) return '';
|
||
|
||
switch (actionType.value) {
|
||
case 'proactive':
|
||
return genBerPcValue(body, tag);
|
||
case 'noaction':
|
||
return tag + '00';
|
||
case 'reference': {
|
||
const ref = (body.querySelector('.error-ref').value || '').replace(/[^0-9a-fA-F]/g, '');
|
||
const refNum = parseInt(ref, 16);
|
||
if (!ref || isNaN(refNum) || refNum < 0x01 || refNum > 0x7F) return '';
|
||
return tag + '01' + refNum.toString(16).padStart(2, '0').toUpperCase();
|
||
}
|
||
default: {
|
||
const hex = (body.querySelector('.ber-hex').value || '').replace(/[^0-9a-fA-F]/g, '');
|
||
if (!hex) return '';
|
||
return tag + berLenStr(hex.length / 2) + hex;
|
||
}
|
||
}
|
||
}
|
||
|
||
function buildScriptChainingRow(rowIdx, hint = '') {
|
||
return `
|
||
<div class="space-y-2">
|
||
<div class="flex gap-4 items-center flex-wrap">
|
||
<span class="text-xs font-medium text-gray-700 dark:text-slate-300">Position:</span>
|
||
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-slate-400">
|
||
<input type="radio" name="chaining-${rowIdx}" value="first" ${hint === 'intermediary' ? '' : 'checked'} onchange="updateChainingFlags(${rowIdx})">
|
||
First Script
|
||
</label>
|
||
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-slate-400">
|
||
<input type="radio" name="chaining-${rowIdx}" value="intermediary" ${hint === 'intermediary' ? 'checked' : ''} onchange="updateChainingFlags(${rowIdx})">
|
||
Intermediary Script
|
||
</label>
|
||
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-slate-400">
|
||
<input type="checkbox" name="chaining-${rowIdx}-last" value="last" ${hint === 'last' ? 'checked' : ''} onchange="updateChainingFlags(${rowIdx})">
|
||
Also Last Script
|
||
</label>
|
||
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-slate-400">
|
||
<input type="checkbox" name="chaining-${rowIdx}-keep" value="keep" onchange="updateChainingFlags(${rowIdx})">
|
||
Keep chaining info across reset (RFM)
|
||
</label>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function updateChainingFlags(rowIdx) {
|
||
const first = document.querySelector(`input[name="chaining-${rowIdx}"][value="first"]`);
|
||
const intermediate = document.querySelector(`input[name="chaining-${rowIdx}"][value="intermediary"]`);
|
||
const lastCheckbox = document.querySelector(`input[name="chaining-${rowIdx}-last"]`);
|
||
const keepCheckbox = document.querySelector(`input[name="chaining-${rowIdx}-keep"]`);
|
||
if (first && intermediate) {
|
||
if (first.checked) intermediate.checked = false;
|
||
if (intermediate.checked) first.checked = false;
|
||
}
|
||
if (lastCheckbox) lastCheckbox.disabled = !!(first && first.checked);
|
||
if (keepCheckbox) keepCheckbox.disabled = !!(first && !first.checked);
|
||
updateBerRow(document.querySelector(`[data-ber-idx="${rowIdx}"] .ber-type`));
|
||
}
|
||
|
||
function genScriptChainingValue(row, tag) {
|
||
const rowIdx = row.dataset.berIdx;
|
||
const firstRadio = row.querySelector(`input[name="chaining-${rowIdx}"][value="first"]`);
|
||
const intermediateRadio = row.querySelector(`input[name="chaining-${rowIdx}"][value="intermediary"]`);
|
||
const lastCheckbox = row.querySelector(`input[name="chaining-${rowIdx}-last"]`);
|
||
const keepCheckbox = row.querySelector(`input[name="chaining-${rowIdx}-keep"]`);
|
||
|
||
let flags = '';
|
||
if (firstRadio && firstRadio.checked) {
|
||
flags = (keepCheckbox && keepCheckbox.checked) ? '11' : '01';
|
||
} else if (intermediateRadio && intermediateRadio.checked) {
|
||
flags = (lastCheckbox && lastCheckbox.checked) ? '03' : '02';
|
||
}
|
||
if (!flags) return '';
|
||
|
||
return tag + '01' + flags;
|
||
}
|
||
|
||
function genBerPcValue(row, tag) {
|
||
const pc = row.querySelector('.ber-pc');
|
||
const pct = pc.querySelector('.ber-pc-type').value;
|
||
const cmdNum = parseInt(pc.querySelector('.ber-pc-num').value)||1;
|
||
const qual = pc.querySelector('.ber-pc-qual').value;
|
||
const src = pc.querySelector('.ber-pc-src').value;
|
||
const dst = pc.querySelector('.ber-pc-dst').value;
|
||
|
||
let value = '8103' + cmdNum.toString(16).padStart(2,'0').toUpperCase();
|
||
const typeBytes = {refresh:'01',display:'21',tone:'20'};
|
||
value += (typeBytes[pct]||'00') + qual;
|
||
value += '8202' + src + dst;
|
||
|
||
if (pct === 'display') {
|
||
const text = pc.querySelector('.ber-pc-text').value;
|
||
const enc = pc.querySelector('.ber-pc-enc').value;
|
||
if (text) {
|
||
if (enc === 'ucs2') {
|
||
const buf = [0x08];
|
||
for (const c of text) { const cd=c.charCodeAt(0); buf.push((cd>>8)&0xFF,cd&0xFF); }
|
||
value += '8D' + berLenStr(buf.length) + buf.map(b=>b.toString(16).padStart(2,'0').toUpperCase()).join('');
|
||
} else {
|
||
try {
|
||
const septets = gsm7TextToSeptets(text);
|
||
const packed = gsm7Encode(septets);
|
||
const inner = Array.from(packed).map(b=>b.toString(16).padStart(2,'0').toUpperCase());
|
||
value += '8D' + berLenStr(inner.length + 1) + '00' + inner.join('');
|
||
} catch(e) {
|
||
value += '8D' + berLenStr(text.length + 1) + '04' + text.split('').map(c=>c.charCodeAt(0).toString(16).padStart(2,'0').toUpperCase()).join('');
|
||
}
|
||
}
|
||
}
|
||
const durEnable = pc.querySelector('.ber-pc-dur-enable');
|
||
if (durEnable && durEnable.checked) {
|
||
const durVal = parseInt(pc.querySelector('.ber-pc-dur-val').value);
|
||
if (!isNaN(durVal) && durVal >= 1 && durVal <= 255) {
|
||
const durUnit = pc.querySelector('.ber-pc-dur-unit').value;
|
||
value += '84' + berLenStr(2) + durUnit + durVal.toString(16).padStart(2,'0').toUpperCase();
|
||
}
|
||
}
|
||
const alpha = (pc.querySelector('.ber-pc-alpha').value||'').replace(/[^0-9a-fA-F]/g,'');
|
||
if (alpha) value += '85' + berLenStr(alpha.length/2) + alpha;
|
||
} else if (pct === 'tone') {
|
||
const tone = pc.querySelector('.ber-pc-tone').value;
|
||
value += '8E01' + tone;
|
||
} else if (pct === 'refresh') {
|
||
const flist = (pc.querySelector('.ber-pc-flist').value||'').replace(/[^0-9a-fA-F]/g,'');
|
||
if (flist) value += '92' + berLenStr(flist.length/2) + flist;
|
||
}
|
||
|
||
return tag + berLenStr(value.length/2) + value;
|
||
}
|
||
|
||
function berLenStr(n) {
|
||
if (n < 128) return n.toString(16).padStart(2,'0').toUpperCase();
|
||
return '81' + n.toString(16).padStart(2,'0').toUpperCase();
|
||
}
|
||
|
||
function genBerTlv() {
|
||
const fmt = document.getElementById('ber-format').value;
|
||
let content = '';
|
||
document.querySelectorAll('.ber-row').forEach(row => {
|
||
const v = genBerRowValue(row);
|
||
if (v) content += v;
|
||
});
|
||
if (!content) { document.getElementById('ber-result').value = ''; return; }
|
||
let result;
|
||
if (fmt === 'AA') {
|
||
result = 'AA' + berLenStr(content.length/2) + content;
|
||
} else {
|
||
result = 'AE80' + content + '0000';
|
||
}
|
||
document.getElementById('ber-result').value = result;
|
||
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'},
|
||
'26': {name:'DISABLE PIN', data:'lc'},
|
||
'28': {name:'ENABLE PIN', data:'lc'},
|
||
'2C': {name:'UNBLOCK PIN', data:'lc'},
|
||
'E6': {name:'INSTALL', data:'lc'},
|
||
'E8': {name:'LOAD', data:'lc'},
|
||
'D4': {name:'RESIZE FILE', data:'lc'},
|
||
'E0': {name:'CREATE FILE', data:'lc'},
|
||
'E4': {name:'DELETE', data:'lc'},
|
||
'F2': {name:'GET STATUS', data:'lc'},
|
||
'CA': {name:'GET DATA', data:'none'},
|
||
'DA': {name:'PUT DATA', data:'lc'},
|
||
'CB': {name:'RETRIEVE DATA', data:'le'},
|
||
'DB': {name:'SET DATA', data:'lc'},
|
||
'A2': {name:'SEARCH RECORD', data:'lc'},
|
||
'32': {name:'INCREASE', data:'lc'},
|
||
'84': {name:'GET CHALLENGE', data:'le'},
|
||
'88': {name:'AUTHENTICATE', data:'lc'},
|
||
'89': {name:'AUTHENTICATE (odd INS)', data:'lc'},
|
||
'E2': {name:'STORE DATA', data:'lc'},
|
||
'F0': {name:'SET STATUS', data:'lc'},
|
||
'82': {name:'EXTERNAL AUTHENTICATE', data:'lc'},
|
||
'C0': {name:'GET RESPONSE', data:'le'},
|
||
'10': {name:'TERMINAL PROFILE', data:'lc'},
|
||
'C2': {name:'ENVELOPE', data:'lc'},
|
||
'12': {name:'FETCH', data:'le'},
|
||
'14': {name:'TERMINAL RESPONSE', data:'lc'},
|
||
'76': {name:'SUSPEND UICC', data:'lc'},
|
||
'AA': {name:'TERMINAL CAPABILITY', data:'lc'},
|
||
'73': {name:'MANAGE SECURE CHANNEL', data:'lc'},
|
||
'75': {name:'TRANSACT DATA', data:'lc'},
|
||
'78': {name:'GET IDENTITY', data:'le'},
|
||
'7A': {name:'EXCHANGE CAPABILITIES', data:'lc'},
|
||
'7C': {name:'MANAGE LSI', data:'lc'},
|
||
'50': {name:'INITIALIZE UPDATE', data:'lc'},
|
||
'D8': {name:'PUT KEY', data:'lc'},
|
||
'70': {name:'MANAGE CHANNEL', data:'none'},
|
||
};
|
||
|
||
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 names = [
|
||
['Security Domain', 'DAP Verification', 'Delegated Management', 'Card Lock', 'Card Terminate', 'Card Reset', 'CVM Management', 'Mandated DAP Verification'],
|
||
['Trusted Path', 'Authorized Management', 'Token Verification', 'Global Delete', 'Global Lock', 'Global Registry', 'Final Application', 'Global Service'],
|
||
['Receipt Generation', 'Ciphered Load File Data Block', 'Contactless Activation', 'Contactless Self-Activation'],
|
||
];
|
||
const parts = [];
|
||
for (let i = 0; i < Math.min(names.length, hex.length / 2); i++) {
|
||
const b = parseInt(hex.substr(i * 2, 2), 16);
|
||
for (let bit = 7; bit >= 0; bit--) {
|
||
const idx = 7 - bit;
|
||
if (b & (1 << bit) && names[i][idx]) parts.push(names[i][idx]);
|
||
}
|
||
}
|
||
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 tryUcs2(hex) {
|
||
if (hex.length < 4 || hex.length % 4 !== 0) return null;
|
||
let out = '';
|
||
for (let i = 0; i < hex.length; i += 4) {
|
||
const cp = parseInt(hex.substr(i, 4), 16);
|
||
if (cp < 0x20 || cp > 0x10FFFF) return null;
|
||
out += String.fromCodePoint(cp);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function uniformHighBytes(hex) {
|
||
const first = hex.substr(0, 2);
|
||
for (let i = 4; i < hex.length; i += 4) {
|
||
if (hex.substr(i, 2) !== first) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function decodeTextData(hex) {
|
||
if (!hex || hex.length < 2) return '?';
|
||
const dcs = parseInt(hex.substr(0, 2), 16);
|
||
const rest = hex.substr(2);
|
||
if (dcs === 0x08) {
|
||
const ucs2 = tryUcs2(rest);
|
||
return ucs2 !== null ? ucs2 : '?';
|
||
}
|
||
if (dcs === 0x00 || dcs === 0x04) {
|
||
const full = tryUcs2(hex);
|
||
if (full !== null && hex.length >= 8 && uniformHighBytes(hex)) return full;
|
||
try {
|
||
const bytes = new Uint8Array(rest.length / 2);
|
||
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(rest.substr(i * 2, 2), 16);
|
||
if (dcs === 0x00) {
|
||
const gsm = gsm7Decode(bytes);
|
||
if (gsm.length > 0) return gsm;
|
||
} else {
|
||
let s = '';
|
||
for (const b of bytes) { if (b < 0x20 || b > 0x7E) return '?'; s += String.fromCharCode(b); }
|
||
if (s.length > 0) return s;
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
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 typeByte = tlv.value.substr(2, 2);
|
||
const qual = tlv.value.substr(4, 2);
|
||
const typeKey = { '01': 'refresh', '21': 'display', '20': 'tone' }[typeByte];
|
||
const cmdName = { refresh: 'REFRESH', display: 'DISPLAY TEXT', tone: 'PLAY TONE' }[typeKey];
|
||
let typeName = 'Command #' + cmdNum;
|
||
if (typeKey && cmdName) {
|
||
const arr = BER_QUAL[typeKey];
|
||
const found = arr ? arr.find(([v]) => v === qual) : null;
|
||
if (found) typeName += ', ' + cmdName + ' qual ' + qual + ' ' + found[1];
|
||
else typeName += ', ' + cmdName + ' qual ' + qual;
|
||
} else {
|
||
typeName += ', type ' + typeByte + ' qual ' + qual;
|
||
}
|
||
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 = parseCompactApdus(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) {
|
||
const val = parseInt(hex, 16);
|
||
if (val >= 0x01 && val <= 0x7F) {
|
||
return [{label:'Reference to EFRMA record', hex, desc:'Record 0x' + hex}];
|
||
}
|
||
const names = {'81':'Proactive session indication','82':'Early response'};
|
||
return [{label:'Action indicator', hex, desc:names[hex] || '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 describeP1P2(ins, p1, p2) {
|
||
const v1 = parseInt(p1, 16);
|
||
const v2 = parseInt(p2, 16);
|
||
let d1 = '', d2 = '';
|
||
if (ins === 'A4') {
|
||
d1 = {'00':'by FID','01':'child DF','03':'parent DF','04':'by DF name (AID)','08':'path from MF','09':'path from current DF'}[p1] || '';
|
||
const resp = (v2 >> 2) & 0x07;
|
||
if (resp === 0x01) d2 = 'return FCP template';
|
||
else if (resp === 0x03) d2 = 'no response data';
|
||
if (v1 === 0x04) {
|
||
const occName = {'00':'first or only occurrence','01':'last occurrence','10':'next occurrence','11':'previous occurrence'}[(v2 & 0x03).toString(2).padStart(2,'0')];
|
||
if (occName) d2 += (d2 ? ', ' : '') + occName;
|
||
}
|
||
} else if (ins === 'B0' || ins === 'D6') {
|
||
if (v1 & 0x80) d1 = 'SFI mode, SFI ' + (v1 & 0x1F);
|
||
else d1 = 'offset high';
|
||
d2 = 'offset low';
|
||
} else if (ins === 'B2' || ins === 'DC') {
|
||
const modeKey = (v2 & 0x07).toString(16).padStart(2, '0');
|
||
d2 = {'02':'next record','03':'previous record','04':'absolute/current'}[modeKey] || '';
|
||
const sfi = (v2 >> 3) & 0x1F;
|
||
if (sfi) d2 += (d2 ? ', ' : '') + 'SFI ' + sfi;
|
||
if ((v2 & 0x07) === 0x02 || (v2 & 0x07) === 0x03) d1 = v1 === 0 ? 'ignored' : 'record # (ignored)';
|
||
else if (v1 > 0) d1 = 'record #' + v1;
|
||
} else if (ins === 'F2') {
|
||
d1 = {'80':'ISD','40':'Applications incl. SSDs','20':'Executable Load Files','10':'ELFs and Modules'}[p1] || '';
|
||
d2 = {'02':'first/all, TLV format','03':'next, TLV format','00':'first/all, deprecated raw','01':'next, deprecated raw'}[p2] || '';
|
||
} else if (ins === 'E6') {
|
||
const roles = [];
|
||
if (v1 & 0x02) roles.push('for load');
|
||
if (v1 & 0x04) roles.push('for install');
|
||
if (v1 & 0x08) roles.push('for make selectable');
|
||
if (v1 & 0x10) roles.push('for extradition');
|
||
if (v1 & 0x20) roles.push('for personalization');
|
||
if (v1 & 0x40) roles.push('for registry update');
|
||
if (roles.length) d1 = roles.join(' + ');
|
||
if (v1 & 0x80) d1 = 'more INSTALL commands follow; ' + d1;
|
||
} else if (ins === 'F0') {
|
||
d1 = {'80':'ISD','40':'Application or SSD','60':'SD + associated apps'}[p1] || '';
|
||
if (p1 === '80') {
|
||
d2 = {'01':'OP_READY','07':'INITIALIZED','0F':'SECURED','7F':'CARD_LOCKED','FF':'TERMINATED'}[p2] || '';
|
||
} else {
|
||
d2 = {'00':'unlock (previous state)','80':'LOCKED'}[p2] || '';
|
||
}
|
||
} else if (ins === '20' || ins === '24') {
|
||
if (v2 >= 1 && v2 <= 8) d2 = 'PIN ref ' + v2;
|
||
}
|
||
return {d1, d2};
|
||
}
|
||
|
||
const RAM_PARAM_TAGS = {
|
||
'C9': 'Application Specific Parameters',
|
||
'C6': 'Volatile memory quotas',
|
||
'C7': 'Non-volatile memory quotas',
|
||
'C8': 'Non-volatile memory quotas (persistent)',
|
||
'CB': 'Global Services parameters',
|
||
'CF': 'Implicit selection parameters',
|
||
'B6': 'Token CRT',
|
||
'EF': 'System Specific Parameters (SIM)',
|
||
'EA': 'System Specific Parameters (UICC)',
|
||
};
|
||
|
||
function readLvField(hex, i) {
|
||
const r = parseBerLen(hex, i);
|
||
const total = r.consumed + r.len * 2;
|
||
if (i + total > hex.length) return null;
|
||
return {value: hex.substr(i + r.consumed, r.len * 2), consumed: total, lenHex: hex.substr(i + 1, r.consumed - 2)};
|
||
}
|
||
|
||
function walkInstallParams(value) {
|
||
const tlvs = parseTlvList(value);
|
||
if (tlvs.length === 0) return null;
|
||
return tlvs.map(tlv => {
|
||
const name = RAM_PARAM_TAGS[tlv.tag];
|
||
let children = null;
|
||
if ((tlv.tag === 'EA' || tlv.tag === 'EF') && tlv.value.length >= 4) {
|
||
children = parseTlvList(tlv.value).map(inner => ({
|
||
label: 'TLV ' + inner.tag + (inner.tag === '80' ? ' (Toolkit parameters)' : inner.tag === 'CA' ? ' (SIM Toolkit parameters)' : ''),
|
||
hex: inner.raw, desc: inner.value || '(empty)',
|
||
}));
|
||
if (!children.length) children = [{label:'Value', hex:tlv.value, desc:tlv.value}];
|
||
}
|
||
const node = {label: name ? 'Tag ' + tlv.tag + ' — ' + name : 'TLV ' + tlv.tag, hex: tlv.raw, desc: tlv.value || '(empty)'};
|
||
if (children) node.children = children;
|
||
return node;
|
||
});
|
||
}
|
||
|
||
function describeInstallDataLv(p1val, data) {
|
||
let fields;
|
||
if (p1val & 0x40) {
|
||
fields = ['SD AID', '(reserved)', 'Application AID', 'Privileges', 'RU parameters', 'RU token'];
|
||
} else if (p1val & 0x20) {
|
||
fields = ['(reserved)', '(reserved)', 'Application AID', 'Privileges', 'Personalization parameters', 'Personalization token'];
|
||
} else if (p1val & 0x10) {
|
||
fields = ['Target SD AID', '(reserved)', 'Application/ELF AID', '(reserved)', 'Extradition parameters', 'Extradition token'];
|
||
} else if ((p1val & 0x08) && !(p1val & 0x04)) {
|
||
fields = ['(reserved)', '(reserved)', 'Application AID', 'Privileges', 'MS parameters', 'MS token'];
|
||
} else if (p1val & 0x04) {
|
||
fields = ['ELF AID', 'Module AID', 'Application AID', 'Privileges', 'Install parameters', 'Install token'];
|
||
} else if (p1val & 0x02) {
|
||
fields = ['Load File AID', 'SD AID', 'LFDB hash', 'Load parameters', 'Load token'];
|
||
} else {
|
||
return null;
|
||
}
|
||
const nodes = [];
|
||
let i = 0;
|
||
for (const name of fields) {
|
||
if (i >= data.length) return null;
|
||
const f = readLvField(data, i);
|
||
if (!f) return null;
|
||
const node = {label: name, hex: f.lenHex + f.value, desc:''};
|
||
if (name === 'Privileges') node.desc = decodePrivileges(f.value);
|
||
else if (name.endsWith('parameters')) {
|
||
const walked = walkInstallParams(f.value);
|
||
node.children = walked || [{label:'Value', hex:f.value, desc:f.value}];
|
||
node.desc = f.value ? '' : '(empty)';
|
||
} else if (f.value) {
|
||
node.desc = f.value;
|
||
} else {
|
||
node.desc = '(empty)';
|
||
}
|
||
nodes.push(node);
|
||
i += f.consumed;
|
||
}
|
||
if (i !== data.length) return null;
|
||
return nodes;
|
||
}
|
||
|
||
function parseOneApdu(hex) {
|
||
const isActivateDeactivate = hex.length >= 4 && (hex.substr(2, 2) === '44' || hex.substr(2, 2) === '04');
|
||
if (hex.length < 10 && !(isActivateDeactivate && hex.length === 8)) 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 labels = describeP1P2(ins, p1, p2);
|
||
const children = [
|
||
{label:'CLA', hex:cla, desc:cla === '80' ? 'GP/RAM' : cla === 'A0' ? 'SIM' : parseInt(cla,16) >= 0x84 && parseInt(cla,16) <= 0x87 ? 'GlobalPlatform (secure messaging)' : 'UICC'},
|
||
{label:'INS', hex:ins, desc:insInfo ? insInfo.name : 'Unknown'},
|
||
{label:'P1', hex:p1, desc:labels.d1},
|
||
{label:'P2', hex:p2, desc:labels.d2},
|
||
];
|
||
if (isActivateDeactivate && hex.length === 8) {
|
||
return {label:'APDU (case 1)', hex:hex, desc:'no data, no Le', children};
|
||
}
|
||
if (ins === 'CA') {
|
||
if (hex.length === 10) {
|
||
children.push({label:'Le', hex:p3, desc:'Expected length: ' + parseInt(p3, 16)});
|
||
return {label:'APDU', hex:hex, desc:'', children};
|
||
}
|
||
const lc = parseInt(p3, 16);
|
||
children.push({label:'Lc', hex:p3, desc:'Data length: ' + lc});
|
||
const data = hex.substr(10, lc * 2);
|
||
children.push({label:'Data', hex:data, desc:data.startsWith('5C') && data.length >= 4 ? 'Tag list: ' + data : data});
|
||
const restLen = hex.length - (10 + lc * 2);
|
||
if (restLen >= 2) children.push({label:'Le', hex:hex.substr(10 + lc * 2, 2), desc:'Expected length: ' + parseInt(hex.substr(10 + lc * 2, 2), 16)});
|
||
return {label:'APDU', hex:hex, desc:'', children};
|
||
}
|
||
if (insInfo && insInfo.data === 'le') {
|
||
children.push({label:'Le', hex:p3, desc:'Expected length: ' + parseInt(p3, 16)});
|
||
} else if (isActivateDeactivate && hex.length >= 10) {
|
||
if (parseInt(p3, 16) === 0 && hex.length === 10) {
|
||
children.push({label:'P3', hex:p3, desc:'empty Lc (legacy form)'});
|
||
} else {
|
||
const alc = parseInt(p3, 16);
|
||
children.push({label:'Lc', hex:p3, desc:'Data length: ' + alc});
|
||
if (alc > 0 && hex.length >= 10 + alc * 2) {
|
||
const adata = hex.substr(10, alc * 2);
|
||
const adesc = adata.length === 4 ? 'FID ' + adata : (adata.length > 4 && adata.length % 2 === 0 ? 'Path ' + adata : adata);
|
||
children.push({label:'Data', hex:adata, desc:adesc});
|
||
}
|
||
}
|
||
} 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);
|
||
let dataDesc = data;
|
||
let dataChildren = null;
|
||
if (ins === 'E6') {
|
||
const lv = describeInstallDataLv(parseInt(p1, 16), data);
|
||
if (lv) { dataDesc = ''; dataChildren = lv; }
|
||
else dataDesc = data;
|
||
} else if (ins === 'F0') {
|
||
if (p1 === '80') dataDesc = 'ignored for ISD';
|
||
else if (data.startsWith('4F') && data.length >= 4 && 2 + 2 + parseInt(data.substr(2, 2), 16) * 2 === data.length) dataDesc = 'Legacy 4F-TLV AID: ' + data.substr(4);
|
||
else dataDesc = 'AID (raw): ' + data;
|
||
}
|
||
const node = {label:'Data', hex:data, desc:dataDesc};
|
||
if (dataChildren) node.children = dataChildren;
|
||
children.push(node);
|
||
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 = hex.length;
|
||
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;
|
||
const fb = parseInt(first, 16);
|
||
const knownCla = first === '80' || first === 'A0' || first === '00' || (fb >= 0x84 && fb <= 0x87);
|
||
if (knownCla && 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 p3Byte = parseInt(hex.substr(i + 6 + consumed, 2), 16);
|
||
const dataType = PARSE_INS[fullIns].data;
|
||
let apduLen = 8;
|
||
if (fullIns === 'CA') {
|
||
const remAvail = hex.length - (i + consumed);
|
||
if (p3Byte > 0 && remAvail >= p3Byte * 2 + 2) apduLen += p3Byte * 2 + 2;
|
||
} else if (fullIns === '44' || fullIns === '04') {
|
||
const avail = hex.length - i;
|
||
const p3Hex = avail >= consumed + 8 ? hex.substr(i + 6 + consumed, 2) : null;
|
||
if (p3Hex !== null) {
|
||
if (p3Byte > 0) apduLen += p3Byte * 2;
|
||
} else if (avail >= consumed + 6) {
|
||
apduLen -= 2;
|
||
}
|
||
} else if (dataType === 'lc') {
|
||
apduLen += p3Byte * 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) {
|
||
if (apdus.length > 0 && hex.length - i === 2) {
|
||
const leHex = hex.substr(i);
|
||
apdus.push({label:'Le', hex:leHex, desc:'Expected response length: ' + parseInt(leHex, 16)});
|
||
} else {
|
||
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 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-sm 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-sm font-mono text-indigo-600 dark:text-indigo-400 ml-2';
|
||
hex.textContent = node.hex;
|
||
header.appendChild(hex);
|
||
}
|
||
if (node.desc) {
|
||
const desc = document.createElement('span');
|
||
desc.className = 'text-sm 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: {
|
||
'9000': 'Normal ending of command',
|
||
'61XX': 'Send GET RESPONSE (Le = SW2)',
|
||
'6CXX': 'Repeat command with Le = SW2',
|
||
'6200': 'Warning: No information given, NV memory unchanged',
|
||
'6281': 'Warning: Part of returned data may be corrupted',
|
||
'6282': 'Warning: End of file/record reached before reading Le bytes',
|
||
'6283': 'Warning: Selected file invalidated',
|
||
'6285': 'Warning: Selected file in termination state',
|
||
'62F1': 'Warning: More data available',
|
||
'62F2': 'Warning: More data available and proactive command pending',
|
||
'62F3': 'Warning: Response data available',
|
||
'63F1': 'Warning: More data expected',
|
||
'63F2': 'Warning: More data expected and proactive command pending',
|
||
'63CX': 'Verification failed, X retries remaining (SW2 & 0x0F = tries; X=0: no counter provided / blocked for VERIFY)',
|
||
'6400': 'No information given, state of NV memory unchanged',
|
||
'6500': 'No information given, state of NV memory changed',
|
||
'6581': 'Memory problem',
|
||
'6700': 'Wrong length in Lc',
|
||
'6800': 'Checking error: No information given (CLA functions)',
|
||
'6881': 'Logical channel not supported',
|
||
'6882': 'Secure messaging not supported',
|
||
'6900': 'No information given',
|
||
'6981': 'Command incompatible with file structure',
|
||
'6982': 'Security status not satisfied',
|
||
'6983': 'Authentication/PIN method blocked',
|
||
'6984': 'Referenced data invalidated',
|
||
'6985': 'Conditions of use not satisfied',
|
||
'6986': 'Command not allowed (no EF selected)',
|
||
'6989': 'Command not allowed — secure channel — security not satisfied',
|
||
'6A80': 'Incorrect parameters in the data field',
|
||
'6A81': 'Function not supported',
|
||
'6A82': 'File not found',
|
||
'6A83': 'Record not found',
|
||
'6A84': 'Not enough memory space',
|
||
'6A86': 'Incorrect parameters P1-P2',
|
||
'6A87': 'Lc inconsistent with P1-P2',
|
||
'6A85': 'Nc inconsistent with TLV data object',
|
||
'6A88': 'Referenced data not found',
|
||
'6A89': 'File already exists',
|
||
'6A8A': 'DF name already exists',
|
||
'6B00': 'Wrong parameter(s) P1-P2',
|
||
'6D00': 'Instruction code not supported or invalid',
|
||
'6E00': 'Class not supported',
|
||
'6F00': 'Technical problem, no precise diagnosis',
|
||
'9850': 'INCREASE cannot be performed, max value reached',
|
||
'9862': 'Authentication error, application specific',
|
||
'9863': 'Security session or association expired',
|
||
'9864': 'Minimum UICC suspension time is too long',
|
||
},
|
||
gp: {
|
||
'6310': 'More data available — issue GET STATUS [get next occurrence]',
|
||
'6581': 'Memory failure',
|
||
'6A80': 'Incorrect values in command data',
|
||
'6A81': 'Function not supported (card Life Cycle State is CARD_LOCKED)',
|
||
'6A82': 'Selected Application / file not found',
|
||
'6A84': 'Not enough memory space',
|
||
'6A88': 'Referenced data not found',
|
||
'6881': 'Logical channel not supported or is not active',
|
||
'6882': 'Secure messaging not supported',
|
||
'6883': 'The last command of the chain was expected',
|
||
'9484': 'Algorithm not supported (DEPRECATED)',
|
||
'9485': 'Invalid key check value (DEPRECATED)',
|
||
'6300': 'Authentication of host cryptogram failed',
|
||
'6200': 'Warning: Logical Channel already closed',
|
||
'6283': 'Warning: Card Life Cycle State is CARD_LOCKED',
|
||
},
|
||
uicc: {
|
||
'91XX': 'Normal ending with proactive UICC command pending (SW2 = length of response data)',
|
||
'9100': 'Normal ending with proactive UICC command pending (no response data)',
|
||
'92XX': 'Normal ending with ongoing data transfer session info',
|
||
'9300': 'SIM Application Toolkit is busy',
|
||
'62F1': 'More data available (ENVELOPE, RETRIEVE DATA, SET DATA)',
|
||
'62F2': 'More data available and proactive command pending',
|
||
'62F3': 'Response data available (ENVELOPE)',
|
||
'63F1': 'More data expected (ENVELOPE, RETRIEVE DATA, SET DATA)',
|
||
'63F2': 'More data expected and proactive command pending (RETRIEVE DATA)',
|
||
'63CX': 'Verification failed, X retries remaining (VERIFY PIN, CHANGE PIN, etc.)',
|
||
'6A80': 'Incorrect parameters in the data field',
|
||
'6A82': 'File not found',
|
||
'6A83': 'Record not found (READ RECORD, SEARCH RECORD, UPDATE RECORD)',
|
||
'6A84': 'Not enough memory space (RETRIEVE DATA, SET DATA)',
|
||
'6A87': 'Lc inconsistent with P1 to P2 (SELECT only)',
|
||
'6A88': 'Referenced data not found',
|
||
'6981': 'Command incompatible with file structure',
|
||
'6983': 'Authentication/PIN method blocked',
|
||
'6984': 'Referenced data invalidated',
|
||
'6986': 'Command not allowed (no EF selected)',
|
||
'6989': 'Command not allowed — secure channel — security not satisfied',
|
||
},
|
||
};
|
||
|
||
const SW_CMD_NAMES = {
|
||
'sim-select': 'SIM / USIM — SELECT',
|
||
'sim-status': 'SIM / USIM — STATUS',
|
||
'sim-read-binary': 'SIM / USIM — READ BINARY',
|
||
'sim-update-binary': 'SIM / USIM — UPDATE BINARY',
|
||
'sim-read-record': 'SIM / USIM — READ RECORD',
|
||
'sim-update-record': 'SIM / USIM — UPDATE RECORD',
|
||
'sim-verify': 'SIM / USIM — VERIFY PIN',
|
||
'sim-change': 'SIM / USIM — CHANGE PIN',
|
||
'sim-disable': 'SIM / USIM — DISABLE PIN',
|
||
'sim-enable': 'SIM / USIM — ENABLE PIN',
|
||
'sim-unblock': 'SIM / USIM — UNBLOCK PIN',
|
||
'sim-deactivate': 'SIM / USIM — DEACTIVATE FILE',
|
||
'sim-activate': 'SIM / USIM — ACTIVATE FILE',
|
||
'sim-challenge': 'SIM / USIM — GET CHALLENGE',
|
||
'sim-auth': 'SIM / USIM — AUTHENTICATE',
|
||
'sim-profile': 'SIM / USIM — TERMINAL PROFILE',
|
||
'sim-envelope': 'SIM / USIM — ENVELOPE',
|
||
'sim-fetch': 'SIM / USIM — FETCH',
|
||
'sim-terminal-rsp': 'SIM / USIM — TERMINAL RESPONSE',
|
||
'sim-manage-channel': 'SIM / USIM — MANAGE CHANNEL',
|
||
'sim-msc': 'SIM / USIM — MANAGE SECURE CHANNEL',
|
||
'sim-retrieve': 'SIM / USIM — RETRIEVE DATA',
|
||
'sim-set-data': 'SIM / USIM — SET DATA',
|
||
'sim-suspend': 'SIM / USIM — SUSPEND UICC',
|
||
'ram-install-load': 'RAM — INSTALL [for load]',
|
||
'ram-install-install': 'RAM — INSTALL [for install]',
|
||
'ram-install-make-sel': 'RAM — INSTALL [for make selectable]',
|
||
'ram-install-reg-update': 'RAM — INSTALL [for registry update]',
|
||
'ram-install-extradition': 'RAM — INSTALL [for extradition]',
|
||
'ram-load': 'RAM — LOAD',
|
||
'ram-delete': 'RAM — DELETE',
|
||
'ram-get-status': 'RAM — GET STATUS',
|
||
'ram-get-data': 'RAM — GET DATA',
|
||
'ram-store-data': 'RAM — STORE DATA',
|
||
'ram-set-status': 'RAM — SET STATUS',
|
||
'ram-ext-auth': 'RAM — EXTERNAL AUTHENTICATE',
|
||
'ram-int-auth': 'RAM — INTERNAL AUTHENTICATE',
|
||
'ram-select': 'RAM — SELECT',
|
||
'ram-init-update': 'RAM — INITIALIZE UPDATE',
|
||
'ram-put-key': 'RAM — PUT KEY',
|
||
'ram-manage-channel': 'RAM — MANAGE CHANNEL',
|
||
};
|
||
|
||
const LIFECYCLE_MAP = {
|
||
'01': 'OP_READY (card) / LOADED (ELF)',
|
||
'03': 'INSTALLED',
|
||
'07': 'SELECTABLE',
|
||
'0F': 'SECURED (card)',
|
||
'1F': 'PERSONALIZED (SD)',
|
||
'7F': 'CARD_LOCKED',
|
||
'FF': 'TERMINATED',
|
||
'83': 'LOCKED (SD)',
|
||
'87': 'LOCKED (SD)',
|
||
'8F': 'LOCKED (SD)',
|
||
};
|
||
|
||
const PRIVILEGE_NAMES = [
|
||
['Security Domain', 'DAP Verification', 'Delegated Management', 'Card Lock', 'Card Terminate', 'Card Reset', 'CVM Management', 'Mandated DAP Verification'],
|
||
['Trusted Path', 'Authorized Management', 'Token Verification', 'Global Delete', 'Global Lock', 'Global Registry', 'Final Application', 'Global Service'],
|
||
['Receipt Generation', 'Ciphered Load File Data Block', 'Contactless Activation', 'Contactless Self-Activation'],
|
||
];
|
||
|
||
function getSwCategory(cmd) {
|
||
if (!cmd) return 'generic';
|
||
return cmd.startsWith('ram-') ? 'gp' : 'uicc';
|
||
}
|
||
|
||
function lookupSw(sw1, sw2, cat) {
|
||
const key = sw1 + sw2;
|
||
const wild = sw1 + 'XX';
|
||
const cx = sw1 + 'CX';
|
||
const maps = [SW_MAP.generic];
|
||
if (cat === 'gp' || cat === 'uicc') maps.push(SW_MAP[cat]);
|
||
if (cat === 'uicc') maps.push(SW_MAP.gp);
|
||
for (const m of maps) {
|
||
if (m[key]) return m[key];
|
||
if (m[wild]) return m[wild];
|
||
if (m[cx] && (parseInt(sw2,16) & 0xF0) === 0xC0) {
|
||
const tries = parseInt(sw2,16) & 0x0F;
|
||
return m[cx].replace('X', tries);
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function updateRespCmd() {
|
||
const cmd = document.getElementById('resp-cmd').value;
|
||
const ctx = document.getElementById('resp-ctx');
|
||
if (!cmd) { ctx.value = 'generic'; return; }
|
||
ctx.value = getSwCategory(cmd) === 'gp' ? 'GlobalPlatform' : 'SIM / UICC';
|
||
}
|
||
|
||
function parseTLV(hex) {
|
||
const tags = [];
|
||
let i = 0;
|
||
while (i < hex.length) {
|
||
let tag = hex.substr(i, 2); i += 2;
|
||
if (tag >= '1F' && tag <= '7F') { tag += hex.substr(i, 2); i += 2; }
|
||
else if (tag >= '9F' && tag <= 'BF') { tag += hex.substr(i, 2); i += 2; }
|
||
else if (tag >= 'DF' && tag <= 'FF') { tag += hex.substr(i, 2); i += 2; }
|
||
let lenByte = parseInt(hex.substr(i, 2), 16); i += 2;
|
||
let len;
|
||
if (lenByte < 0x80) { len = lenByte; }
|
||
else if (lenByte === 0x81) { len = parseInt(hex.substr(i, 2), 16); i += 2; }
|
||
else if (lenByte === 0x82) { len = parseInt(hex.substr(i, 4), 16); i += 4; }
|
||
else { len = lenByte; }
|
||
const value = hex.substr(i, len * 2); i += len * 2;
|
||
const isConstructed = parseInt(tag, 16) >= 0xA0 && parseInt(tag, 16) < 0xC0;
|
||
const children = isConstructed ? parseTLV(value) : null;
|
||
tags.push({ tag, len, value, children, isConstructed });
|
||
}
|
||
return tags;
|
||
}
|
||
|
||
function formatTlvTree(tags, indent) {
|
||
if (!indent) indent = 0;
|
||
const pad = ' '.repeat(indent);
|
||
let html = '';
|
||
for (const t of tags) {
|
||
const tagName = TLV_TAG_NAMES[t.tag] || '';
|
||
html += pad + '<span class="text-blue-600 dark:text-blue-400">' + t.tag + '</span>';
|
||
html += ' <span class="text-gray-500">len=' + t.len + '</span>';
|
||
if (tagName) html += ' <span class="text-green-600 dark:text-green-400">// ' + tagName + '</span>';
|
||
if (t.children && t.children.length) {
|
||
html += '\n' + formatTlvTree(t.children, indent + 1);
|
||
} else {
|
||
const decoded = decodeTlvValue(t);
|
||
if (decoded) html += ' <span class="text-purple-600 dark:text-purple-400">→ ' + decoded + '</span>';
|
||
html += '\n';
|
||
}
|
||
}
|
||
return html;
|
||
}
|
||
|
||
const TLV_TAG_NAMES = {
|
||
'4F': 'Application AID',
|
||
'5F50': 'SD Manager URL',
|
||
'9F70': 'Life Cycle State',
|
||
'C5': 'Privileges',
|
||
'C4': 'Application ELF AID',
|
||
'CC': 'Associated SD AID',
|
||
'CE': 'ELF Version Number',
|
||
'84': 'Executable Module AID',
|
||
'CF': 'Implicit Selection Parameter',
|
||
'E3': 'GP Registry Data',
|
||
'42': 'Issuer Identification Number',
|
||
'45': 'Card Image Number',
|
||
'66': 'Card Data',
|
||
'67': 'Card Capability Information',
|
||
'E0': 'Key Information Template',
|
||
'C0': 'Key Information Data',
|
||
'D3': 'Current Security Level',
|
||
'C1': 'Sequence Counter',
|
||
'C2': 'Confirmation Counter',
|
||
'5C': 'Tag List',
|
||
'7F21': 'Certificate',
|
||
'5031': 'Certificate Info (EF.OD)',
|
||
'6F': 'FCI Template',
|
||
'62': 'FCP Template',
|
||
'64': 'FMD Template',
|
||
'A0': 'Application Template',
|
||
'A5': 'Proprietary FCI Data',
|
||
'81': 'Immediate Action',
|
||
'82': 'Error Action',
|
||
'22': 'C-APDU',
|
||
'83': 'Script Chaining',
|
||
};
|
||
|
||
function decodeTlvValue(t) {
|
||
if (t.tag === '9F70' && t.value.length === 2) {
|
||
return LIFECYCLE_MAP[t.value] || 'Unknown state (' + t.value + ')';
|
||
}
|
||
if (t.tag === 'C5' && t.value.length >= 2) {
|
||
return decodePrivileges(t.value);
|
||
}
|
||
if (t.tag === '4F') {
|
||
return t.value.replace(/(..)/g, '$1 ').trim();
|
||
}
|
||
if (t.tag === 'C4' || t.tag === 'CC' || t.tag === '84') {
|
||
return t.value.replace(/(..)/g, '$1 ').trim();
|
||
}
|
||
if (t.tag === 'CE' && t.value.length === 4) {
|
||
return 'v' + parseInt(t.value.substr(0,2),16) + '.' + parseInt(t.value.substr(2,2),16);
|
||
}
|
||
if (t.tag === 'D3' && t.value.length === 2) {
|
||
const v = parseInt(t.value,16);
|
||
const parts = [];
|
||
if (v & 0x80) parts.push('C-MAC');
|
||
if (v & 0x40) parts.push('MAC');
|
||
if (v & 0x20) parts.push('R-MAC');
|
||
if (v & 0x10) parts.push('R-CC');
|
||
if (v & 0x08) parts.push('C-DEC');
|
||
if (v & 0x04) parts.push('D-DEC');
|
||
if (v & 0x02) parts.push('C-ENC');
|
||
if (v & 0x01) parts.push('D-ENC');
|
||
return parts.length ? parts.join(' + ') : 'No security';
|
||
}
|
||
if (t.tag === 'C1' && t.value.length >= 2) {
|
||
return 'Seq: 0x' + t.value;
|
||
}
|
||
if (t.tag === 'C2' && t.value.length >= 2) {
|
||
return 'Counter: 0x' + t.value;
|
||
}
|
||
if (t.tag === '5F50') {
|
||
try {
|
||
const bytes = [];
|
||
for (let i = 0; i < t.value.length; i += 2) bytes.push(parseInt(t.value.substr(i,2),16));
|
||
return new TextDecoder().decode(new Uint8Array(bytes));
|
||
} catch(e) { return t.value; }
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function decodePrivileges(hex) {
|
||
const bytes = [];
|
||
for (let i = 0; i < hex.length; i += 2) bytes.push(parseInt(hex.substr(i,2),16));
|
||
const names = [];
|
||
for (let b = 0; b < Math.min(bytes.length, PRIVILEGE_NAMES.length); b++) {
|
||
const byteNames = PRIVILEGE_NAMES[b] || [];
|
||
for (let bit = 0; bit < byteNames.length; bit++) {
|
||
if (bytes[b] & (0x80 >> bit)) {
|
||
if (byteNames[bit]) names.push(byteNames[bit]);
|
||
}
|
||
}
|
||
}
|
||
return names.length ? names.join(', ') : 'None';
|
||
}
|
||
|
||
function decodeResponse() {
|
||
const cmd = document.getElementById('resp-cmd').value;
|
||
const swHex = (document.getElementById('resp-sw').value || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase().padStart(4, '0').slice(0, 4);
|
||
const sw1 = swHex.slice(0, 2);
|
||
const sw2 = swHex.slice(2, 4);
|
||
const rawData = (document.getElementById('resp-data').value || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
|
||
const out = document.getElementById('resp-output');
|
||
let html = '';
|
||
|
||
if (!sw1 || !sw2) {
|
||
html += '<span class="text-red-500">Please enter SW1 and SW2</span>';
|
||
out.innerHTML = html;
|
||
return;
|
||
}
|
||
|
||
const cat = getSwCategory(cmd);
|
||
const swDesc = lookupSw(sw1, sw2, cat);
|
||
const isOk = sw1 === '90' && sw2 === '00';
|
||
|
||
html += '<div class="mb-2">';
|
||
if (isOk) {
|
||
html += '<span class="text-green-600 dark:text-green-400 font-bold">✓</span> ';
|
||
} else {
|
||
html += '<span class="text-red-500 dark:text-red-400 font-bold">✗</span> ';
|
||
}
|
||
html += 'SW1=' + sw1 + ' SW2=' + sw2;
|
||
if (cmd && SW_CMD_NAMES[cmd]) html += ' <span class="text-gray-500">(' + SW_CMD_NAMES[cmd] + ')</span>';
|
||
html += '<br>';
|
||
if (swDesc) {
|
||
html += '<span class="text-gray-700 dark:text-slate-300">' + swDesc + '</span>';
|
||
} else {
|
||
html += '<span class="text-yellow-600 dark:text-yellow-400">Unknown status word</span>';
|
||
}
|
||
html += '</div>';
|
||
|
||
if (rawData) {
|
||
html += '<div class="mt-2 pt-2 border-t border-gray-300 dark:border-slate-600">';
|
||
html += '<div class="text-sm font-medium text-gray-700 dark:text-slate-300 mb-1">Response Data</div>';
|
||
if (cmd === 'ram-get-status') {
|
||
const tlv = parseTLV(rawData);
|
||
if (tlv.length) {
|
||
html += '<pre class="text-xs leading-relaxed">' + formatTlvTree(tlv) + '</pre>';
|
||
} else {
|
||
html += '<span class="text-gray-500">' + rawData.replace(/(..)/g, '$1 ').trim() + '</span>';
|
||
}
|
||
} else if (cmd === 'ram-get-data') {
|
||
const tlv = parseTLV(rawData);
|
||
if (tlv.length) {
|
||
html += '<pre class="text-xs leading-relaxed">' + formatTlvTree(tlv) + '</pre>';
|
||
} else {
|
||
html += '<span class="text-gray-500">' + rawData.replace(/(..)/g, '$1 ').trim() + '</span>';
|
||
}
|
||
} else if (cmd === 'sim-select' || cmd === 'ram-select') {
|
||
const tlv = parseTLV(rawData);
|
||
if (tlv.length) {
|
||
html += '<pre class="text-xs leading-relaxed">' + formatTlvTree(tlv) + '</pre>';
|
||
} else {
|
||
html += '<span class="text-gray-500">' + rawData.replace(/(..)/g, '$1 ').trim() + '</span>';
|
||
}
|
||
} else {
|
||
html += '<span class="text-gray-500">' + rawData.replace(/(..)/g, '$1 ').trim() + '</span>';
|
||
}
|
||
html += '</div>';
|
||
}
|
||
|
||
out.innerHTML = html;
|
||
}
|
||
|
||
// ===== pysim integration =====
|
||
// Default to a local server on 127.0.0.1:8080. If this PWA is served by
|
||
// pysim-otaman-server itself (same origin), detect the co-located API once
|
||
// and switch to a relative base; otherwise keep the default and wait for the
|
||
// user to point at a server.
|
||
let pysimBase = 'http://127.0.0.1:8080';
|
||
|
||
async function pysimDetectSameOrigin() {
|
||
try {
|
||
const res = await fetch('/api/version', { method: 'GET', cache: 'no-store' });
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
if (!data || typeof data.version !== 'string') return;
|
||
pysimBase = '';
|
||
const urlEl = document.getElementById('pysim-url');
|
||
if (urlEl) { urlEl.value = ''; urlEl.placeholder = '(same origin)'; }
|
||
_pysimAutoConnectAttempted = true;
|
||
pysimConnect();
|
||
} catch (e) {
|
||
// no co-located API (file://, plain static server, SPA fallback) — keep default
|
||
}
|
||
}
|
||
pysimDetectSameOrigin();
|
||
|
||
function esc(str) {
|
||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
async function pysimFetch(path, body) {
|
||
const url = pysimBase + path;
|
||
const opts = { method: body ? 'POST' : 'GET', headers: { 'Content-Type': 'application/json', 'Accept-Language': currentLang } };
|
||
if (body) opts.body = JSON.stringify(body);
|
||
const res = await fetch(url, opts);
|
||
return await res.json();
|
||
}
|
||
|
||
function pysimTargetSpace() {
|
||
let h;
|
||
try { h = new URL(pysimBase).hostname.toLowerCase(); } catch (e) { return 'public'; }
|
||
if (h === 'localhost' || h === '[::1]' || h === '::1' || /^127\./.test(h)) return 'loopback';
|
||
if (/^10\./.test(h) || /^192\.168\./.test(h) || /^172\.(1[6-9]|2\d|3[01])\./.test(h)) return 'private';
|
||
return 'public';
|
||
}
|
||
|
||
function pysimIsLikelyBlocked() {
|
||
const pagePublic = location.protocol === 'https:'
|
||
|| !(location.hostname === 'localhost' || /^127\./.test(location.hostname));
|
||
const s = pysimTargetSpace();
|
||
return pagePublic && (s === 'loopback' || s === 'private');
|
||
}
|
||
|
||
function pysimSetConnected(state) {
|
||
const img = document.getElementById('pysim-status-img');
|
||
if (state === 'spin') {
|
||
img.src = 'sim_anim.svg';
|
||
} else {
|
||
img.src = state ? 'sim.svg' : 'nosim.svg';
|
||
}
|
||
}
|
||
|
||
function pysimShowConnected(connected) {
|
||
document.getElementById('pysim-connect-row').classList.toggle('hidden', connected);
|
||
document.getElementById('pysim-connect-info').classList.toggle('hidden', connected);
|
||
document.getElementById('pysim-connected-row').classList.toggle('hidden', !connected);
|
||
document.getElementById('pysim-body').classList.toggle('hidden', !connected);
|
||
document.getElementById('sp-verify-btn').classList.toggle('hidden', !connected);
|
||
document.getElementById('sp-send-btn').classList.toggle('hidden', !connected);
|
||
}
|
||
|
||
async function pysimRefresh() {
|
||
const statusEl = document.getElementById('pysim-status');
|
||
try {
|
||
const data = await pysimFetch('/api/status');
|
||
pysimSetConnected(true);
|
||
let html = 'Reader: <b>' + esc(data.reader || 'none') + '</b>' +
|
||
' | Card: <b>' + esc(data.card || 'none') + '</b>' +
|
||
' | Profile: <b>' + esc(data.profile || 'none') + '</b><br>' +
|
||
'ATR: <b>' + esc(data.atr || 'n/a') + '</b>' +
|
||
(data.cla_byte ? ' | CLA: <b>' + esc(data.cla_byte) + '</b>' : '') +
|
||
(data.sel_ctrl ? ' | SEL: <b>' + esc(data.sel_ctrl) + '</b>' : '') +
|
||
' | Channels: <b>' + (data.channels && data.channels.length ? data.channels.join(', ') : 'none') + '</b><br>' +
|
||
'ADM: <b>' + (data.adm_verified ? 'verified' : 'not verified') + '</b>' +
|
||
' | Selection: <b>' + (data.current_selection
|
||
? (data.current_selection.name || data.current_selection.fid || '?')
|
||
: 'none') + '</b>' +
|
||
(data.current_selection
|
||
? ' [' + (data.current_selection.type || '?') + ']' +
|
||
' FID=' + (data.current_selection.fid || '?') +
|
||
(data.current_selection.path ? ' path=' + data.current_selection.path : '') +
|
||
(data.current_selection.file_size ? ' size=' + data.current_selection.file_size : '') +
|
||
(data.current_selection.record_len ? ' rec_len=' + data.current_selection.record_len : '') +
|
||
(data.current_selection.num_of_rec ? ' rec_cnt=' + data.current_selection.num_of_rec : '')
|
||
: '');
|
||
statusEl.innerHTML = html;
|
||
return true;
|
||
} catch (e) {
|
||
pysimSetConnected(false);
|
||
statusEl.textContent = t('Connection failed:') + ' ' + e.message;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function pysimConnect() {
|
||
const urlInput = document.getElementById('pysim-url');
|
||
pysimBase = urlInput.value.replace(/\/+$/, '');
|
||
const wasAuto = _pysimAutoConnectAttempted;
|
||
_pysimAutoConnectAttempted = false;
|
||
const btn = document.getElementById('pysim-connect-btn');
|
||
btn.disabled = true;
|
||
btn.textContent = t('Connecting...');
|
||
const statusEl = document.getElementById('pysim-status');
|
||
const msgEl = document.getElementById('pysim-connect-msg');
|
||
msgEl.innerHTML = '<span class="text-gray-500">' + t('Checking card...') + '</span>';
|
||
pysimSetConnected('spin');
|
||
try {
|
||
// Version check
|
||
const ver = await pysimFetch('/api/version');
|
||
if (!ver.version) throw new Error('No version');
|
||
const sv = (ver.version || '').split('.').map(Number);
|
||
if (sv[0] < 1 || (sv[0] === 1 && sv[1] < 0)) {
|
||
msgEl.innerHTML = '<span class="text-red-500">Server version ' + esc(ver.version) + ' is outdated. Please update pysim-otaman-server.</span>';
|
||
btn.textContent = t('Connect');
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
if (sv[0] > 1) {
|
||
msgEl.innerHTML = '<span class="text-yellow-500">Warning: server version ' + esc(ver.version) + ' is newer than expected. Update OTAMan for full compatibility.</span>';
|
||
}
|
||
const status = await pysimFetch('/api/status');
|
||
if (!status.app_ready) {
|
||
throw new Error('Server not ready');
|
||
}
|
||
const cmd = await pysimFetch('/api/command', { cmd: 'status' });
|
||
if (cmd.output && (cmd.output.includes('Exception') || cmd.output.includes('ProtocolError'))) {
|
||
pysimSetConnected(false);
|
||
statusEl.innerHTML = '<span class="text-red-500">' + t('No card detected. Insert card and click Equip card button') + '</span>';
|
||
pysimShowConnected(true);
|
||
btn.textContent = t('Connected');
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
pysimSetConnected(true);
|
||
pysimShowConnected(true);
|
||
await pysimRefresh();
|
||
pysimLoadCommands();
|
||
pysimFsRefresh();
|
||
pysimPollStatusInit();
|
||
pysimStartBackendPoll();
|
||
btn.textContent = t('Connected');
|
||
stkCheckMenu();
|
||
} catch (e) {
|
||
if (wasAuto) _pysimAutoConnectAttempted = true;
|
||
pysimSetConnected(false);
|
||
const hint = t(pysimIsLikelyBlocked() ? 'pysim-blocked-hint' : 'pysim-unreachable-hint')
|
||
.replace('{url}', esc(pysimBase));
|
||
msgEl.innerHTML =
|
||
'<span class="text-red-500">' + t('Connection failed:') + ' ' + esc(e.message) + '</span>' +
|
||
'<br><span class="text-sm text-gray-600 dark:text-slate-400">' + hint + '</span>';
|
||
btn.textContent = t('Connect');
|
||
}
|
||
btn.disabled = false;
|
||
}
|
||
|
||
function pysimDisconnect() {
|
||
if (_pysimPollTimer) { clearInterval(_pysimPollTimer); _pysimPollTimer = null; }
|
||
pysimShowConnected(false);
|
||
document.getElementById('pysim-status').textContent = '';
|
||
pysimSetConnected(false);
|
||
}
|
||
|
||
async function pysimRunCmd(cmd) {
|
||
const statusEl = document.getElementById('pysim-status');
|
||
try {
|
||
const result = await pysimFetch('/api/command', { cmd });
|
||
if (result.output && (result.output.includes('Exception') || result.output.includes('ProtocolError') || result.output.includes('SwMatchError'))) {
|
||
pysimSetConnected(false);
|
||
statusEl.innerHTML = '<span class="text-red-500">' + t('No card detected. Insert card and click Equip card button') + '</span>';
|
||
return null;
|
||
}
|
||
return result;
|
||
} catch (e) {
|
||
pysimSetConnected(false);
|
||
statusEl.innerHTML = '<span class="text-red-500">' + t('No card detected. Insert card and click Equip card button') + '</span>';
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function pysimStatusCmd() {
|
||
const statusEl = document.getElementById('pysim-status');
|
||
statusEl.innerHTML = '<span class="text-gray-500">' + t('Checking...') + '</span>';
|
||
pysimSetConnected('spin');
|
||
const result = await pysimRunCmd('status');
|
||
if (result) await pysimRefresh();
|
||
}
|
||
|
||
async function pysimResetCmd() {
|
||
const statusEl = document.getElementById('pysim-status');
|
||
statusEl.innerHTML = '<span class="text-gray-500">' + t('Resetting...') + '</span>';
|
||
pysimSetConnected('spin');
|
||
const result = await pysimRunCmd('reset');
|
||
if (result) {
|
||
await pysimRefresh();
|
||
pysimFsRefresh();
|
||
}
|
||
}
|
||
|
||
async function pysimEquipCmd() {
|
||
const statusEl = document.getElementById('pysim-status');
|
||
statusEl.innerHTML = '<span class="text-gray-500">' + t('Equipping...') + '</span>';
|
||
pysimSetConnected('spin');
|
||
const result = await pysimRunCmd('equip');
|
||
if (result) {
|
||
await pysimRefresh();
|
||
pysimFsRefresh();
|
||
stkCheckMenu();
|
||
}
|
||
}
|
||
|
||
async function pysimExec() {
|
||
const cmd = document.getElementById('pysim-cmd').value.trim();
|
||
if (!cmd) return;
|
||
const out = document.getElementById('pysim-cmd-output');
|
||
out.value = '> ' + cmd + '\n';
|
||
try {
|
||
const data = await pysimFetch('/api/command', { cmd });
|
||
out.value += data.output || '(no output)';
|
||
} catch (e) {
|
||
out.value += 'Error: ' + e.message;
|
||
}
|
||
pysimRefresh();
|
||
}
|
||
|
||
async function pysimApdu() {
|
||
const apdu = document.getElementById('pysim-apdu').value.replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (!apdu) return;
|
||
const out = document.getElementById('pysim-apdu-output');
|
||
out.value = '> ' + apdu + '\n';
|
||
try {
|
||
const data = await pysimFetch('/api/apdu', { apdu });
|
||
if (data.error) {
|
||
out.value += 'Error: ' + data.error;
|
||
} else {
|
||
out.value += 'Response: ' + data.response + '\nSW: ' + data.sw;
|
||
}
|
||
} catch (e) {
|
||
out.value += 'Error: ' + e.message;
|
||
}
|
||
pysimRefresh();
|
||
}
|
||
|
||
function spParams() {
|
||
return {
|
||
spi1: document.getElementById('sp-spi1').value,
|
||
spi2: document.getElementById('sp-spi2-hex').value,
|
||
kic: document.getElementById('sp-kic-hex').value,
|
||
kid: document.getElementById('sp-kid-hex').value,
|
||
tar: document.getElementById('sp-tar').value,
|
||
cntr: document.getElementById('sp-cntr').value,
|
||
kicKey: document.getElementById('sp-kic-key').value,
|
||
kidKey: document.getElementById('sp-kid-key').value,
|
||
apdu: document.getElementById('sp-apdu').value,
|
||
};
|
||
}
|
||
|
||
async function pysimVerifySp() {
|
||
const sp = document.getElementById('sp-result').value.replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
const resultEl = document.getElementById('sp-verify-result');
|
||
if (!sp) { alert('No secured packet to verify. Generate a secure packet first.'); return; }
|
||
resultEl.classList.remove('hidden');
|
||
resultEl.textContent = t('Verifying against pySim reference...');
|
||
try {
|
||
const data = await pysimFetch('/api/sp-verify', { sp, ...spParams() });
|
||
if (data.match) {
|
||
resultEl.textContent = 'MATCH: JS packet byte-identical to pySim reference (' + sp.length / 2 + ' bytes)';
|
||
resultEl.classList.remove('text-red-600');
|
||
resultEl.classList.add('text-green-600');
|
||
} else {
|
||
let msg = 'MISMATCH vs pySim.';
|
||
if (data.diffs) msg += ' First diff at byte ' + data.diffs[0].offset + ' (JS=' + data.diffs[0].js + ' pySim=' + data.diffs[0].ref + ')';
|
||
if (data.py_sp) msg += ' pySim reference: ' + data.py_sp;
|
||
resultEl.textContent = msg;
|
||
resultEl.classList.remove('text-green-600');
|
||
resultEl.classList.add('text-red-600');
|
||
}
|
||
} catch (e) {
|
||
resultEl.textContent = 'Error: ' + e.message;
|
||
resultEl.classList.remove('text-green-600');
|
||
resultEl.classList.add('text-red-600');
|
||
}
|
||
}
|
||
|
||
async function pysimSendOta() {
|
||
const connectedRow = document.getElementById('pysim-connected-row');
|
||
if (!connectedRow || connectedRow.classList.contains('hidden')) {
|
||
alert('Card reader server is not connected');
|
||
return;
|
||
}
|
||
const sp = document.getElementById('sp-result').value.replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (!sp) { alert('No secured packet to send. Generate a secure packet first.'); return; }
|
||
const statusEl = document.getElementById('pysim-status');
|
||
const sendResultEl = document.getElementById('sp-send-result');
|
||
sendResultEl.classList.add('hidden');
|
||
statusEl.innerHTML = '<span class="text-gray-500">' + t('Sending OTA...') + '</span>';
|
||
try {
|
||
const data = await pysimFetch('/api/send-ota', { sp, ...spParams() });
|
||
if (data.success) {
|
||
if (data.por && data.por.decoded) {
|
||
document.getElementById('resp-sw').value = data.por.decoded.last_status_word || '';
|
||
document.getElementById('resp-data').value = data.por.decoded.last_response_data || '';
|
||
switchTab('response');
|
||
}
|
||
let msg = 'OTA sent. SW: ' + data.sw;
|
||
const por = data.por;
|
||
if (por && por.response_status) {
|
||
msg += ' | PoR status: ' + por.response_status + ' (TAR ' + por.tar + ')';
|
||
if (por.cntr) msg += ' | PoR CNTR: ' + por.cntr;
|
||
if (por.decoded && por.decoded.last_status_word) msg += ' | last SW ' + por.decoded.last_status_word + ' ' + (por.decoded.last_response_data || '');
|
||
}
|
||
sendResultEl.textContent = msg;
|
||
if (por && por.raw) {
|
||
const rawLine = document.createElement('div');
|
||
rawLine.className = 'break-all';
|
||
rawLine.textContent = 'raw PoR: ' + por.raw;
|
||
sendResultEl.appendChild(rawLine);
|
||
}
|
||
sendResultEl.classList.remove('hidden', 'text-red-600');
|
||
sendResultEl.classList.add('text-green-600');
|
||
statusEl.innerHTML = '<span class="text-green-600">' + esc(msg) + '</span>';
|
||
} else {
|
||
let msg = 'OTA failed';
|
||
if (data.error) msg += ': ' + data.error;
|
||
if (data.sw) msg += (data.error ? ' | ' : ': ') + 'SW ' + data.sw;
|
||
sendResultEl.textContent = msg;
|
||
sendResultEl.classList.remove('hidden', 'text-green-600');
|
||
sendResultEl.classList.add('text-red-600');
|
||
statusEl.innerHTML = '<span class="text-red-500">' + esc(msg) + '</span>';
|
||
}
|
||
} catch (e) {
|
||
sendResultEl.textContent = 'Error: ' + e.message;
|
||
sendResultEl.classList.remove('hidden', 'text-green-600');
|
||
sendResultEl.classList.add('text-red-600');
|
||
statusEl.innerHTML = '<span class="text-red-500">Error: ' + esc(e.message) + '</span>';
|
||
}
|
||
}
|
||
|
||
// ===== STK Menu Browser =====
|
||
let stkMenuStack = [];
|
||
|
||
async function stkCheckMenu() {
|
||
try {
|
||
console.log('stkCheckMenu called');
|
||
const data = await pysimFetch('/api/menu');
|
||
console.log('menu data:', data);
|
||
const btn = document.getElementById('stk-menu-btn');
|
||
if (data && data.title && data.items && data.items.length) {
|
||
document.getElementById('stk-menu-title').textContent = data.title;
|
||
btn.style.display = '';
|
||
} else {
|
||
btn.style.display = 'none';
|
||
}
|
||
} catch (e) {
|
||
console.error('stkCheckMenu error:', e);
|
||
}
|
||
}
|
||
|
||
function stkSetPillsDisabled(disabled) {
|
||
document.querySelectorAll('.pysim-subtab').forEach(b => {
|
||
if (b.id === 'stk-menu-btn') return;
|
||
b.disabled = disabled;
|
||
b.classList.toggle('opacity-50', disabled);
|
||
});
|
||
}
|
||
|
||
function stkMenuOpen() {
|
||
document.getElementById('stk-menu-panel-title').textContent = document.getElementById('stk-menu-title').textContent;
|
||
stkSetPillsDisabled(true);
|
||
stkMenuStack = [];
|
||
document.getElementById('stk-back-btn').style.display = 'none';
|
||
document.getElementById('stk-menu-buttons').classList.add('hidden');
|
||
document.getElementById('stk-menu-panel').classList.remove('hidden');
|
||
stkMenuRenderItems();
|
||
}
|
||
|
||
function stkMenuClose() {
|
||
document.getElementById('stk-menu-panel').classList.add('hidden');
|
||
stkSetPillsDisabled(false);
|
||
}
|
||
|
||
async function stkMenuExit() {
|
||
try {
|
||
await pysimFetch('/api/menu-respond', { result: 'cancel' });
|
||
} catch (e) {}
|
||
stkMenuClose();
|
||
}
|
||
|
||
function stkMenuRenderItems() {
|
||
const content = document.getElementById('stk-menu-panel-content');
|
||
content.innerHTML = '<span class="text-gray-500 text-sm">Loading...</span>';
|
||
pysimFetch('/api/menu').then(data => {
|
||
if (!data || !data.items) { content.innerHTML = '<span class="text-red-500 text-sm">No menu data</span>'; return; }
|
||
let html = '<div class="space-y-1">';
|
||
data.items.forEach(item => {
|
||
html += '<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-slate-700 cursor-pointer" onclick="stkMenuItemClick(' + item.id + ')"><span class="w-5 text-xs text-gray-300 dark:text-gray-600 text-right">' + item.id + '</span><span class="text-sm">' + esc(item.text) + '</span></div>';
|
||
});
|
||
html += '</div>';
|
||
content.innerHTML = html;
|
||
}).catch(() => { content.innerHTML = '<span class="text-red-500 text-sm">Error loading menu</span>'; });
|
||
}
|
||
|
||
async function stkMenuItemClick(itemId) {
|
||
stkSetPillsDisabled(true);
|
||
const content = document.getElementById('stk-menu-panel-content');
|
||
content.innerHTML = '<span class="text-gray-500 text-sm">Sending...</span>';
|
||
try {
|
||
const data = await pysimFetch('/api/menu-select', { item_id: itemId });
|
||
stkMenuHandleResponse(data);
|
||
} catch (e) {
|
||
content.innerHTML = '<span class="text-red-500 text-sm">Error: ' + esc(e.message) + '</span>';
|
||
stkSetPillsDisabled(false);
|
||
}
|
||
}
|
||
|
||
function stkMenuHandleResponse(data) {
|
||
const content = document.getElementById('stk-menu-panel-content');
|
||
const backBtn = document.getElementById('stk-back-btn');
|
||
const btns = document.getElementById('stk-menu-buttons');
|
||
btns.classList.add('hidden');
|
||
if (data.type === 'display_text') {
|
||
content.innerHTML = '<div class="text-sm whitespace-pre-wrap p-2 bg-gray-50 dark:bg-slate-700 rounded">' + esc(data.text) + '</div>';
|
||
backBtn.style.display = '';
|
||
document.getElementById('stk-ok-btn').style.display = '';
|
||
btns.classList.remove('hidden');
|
||
} else if (data.type === 'select_item') {
|
||
stkMenuStack.push(data.items);
|
||
content.innerHTML = '';
|
||
const items = data.items;
|
||
let html = '<div class="space-y-1">';
|
||
items.forEach(item => {
|
||
html += '<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-slate-700 cursor-pointer" onclick="stkSubItemClick(' + item.id + ')"><span class="w-5 text-xs text-gray-300 dark:text-gray-600 text-right">' + item.id + '</span><span class="text-sm">' + esc(item.text) + '</span></div>';
|
||
});
|
||
html += '</div>';
|
||
content.innerHTML = html;
|
||
backBtn.style.display = '';
|
||
document.getElementById('stk-ok-btn').style.display = 'none';
|
||
btns.classList.remove('hidden');
|
||
} else {
|
||
content.innerHTML = '<span class="text-gray-500 text-sm">SW: ' + esc(data.sw || '?') + '</span>';
|
||
stkSetPillsDisabled(false);
|
||
backBtn.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
async function stkSubItemClick(itemId) {
|
||
const content = document.getElementById('stk-menu-panel-content');
|
||
content.innerHTML = '<span class="text-gray-500 text-sm">Sending...</span>';
|
||
try {
|
||
const data = await pysimFetch('/api/menu-respond', { result: 'ok', item_id: itemId });
|
||
stkMenuHandleResponse(data);
|
||
} catch (e) {
|
||
content.innerHTML = '<span class="text-red-500 text-sm">Error: ' + esc(e.message) + '</span>';
|
||
}
|
||
}
|
||
|
||
async function stkMenuRespond(result) {
|
||
try {
|
||
const data = await pysimFetch('/api/menu-respond', { result: result });
|
||
if (result === 'cancel' || result === 'back' || result === 'timeout') {
|
||
stkMenuRenderItems();
|
||
document.getElementById('stk-menu-buttons').classList.add('hidden');
|
||
document.getElementById('stk-back-btn').style.display = 'none';
|
||
return;
|
||
}
|
||
stkMenuHandleResponse(data);
|
||
} catch (e) {
|
||
document.getElementById('stk-menu-panel-content').innerHTML = '<span class="text-red-500 text-sm">Error: ' + esc(e.message) + '</span>';
|
||
}
|
||
}
|
||
|
||
// ===== Cards storage =====
|
||
let cards = [];
|
||
|
||
function cardsLoad() {
|
||
try {
|
||
const d = localStorage.getItem('otaman_cards');
|
||
cards = d ? JSON.parse(d) : [];
|
||
} catch (e) { cards = []; }
|
||
cardsRebuildSelect();
|
||
}
|
||
|
||
function cardsSave() {
|
||
localStorage.setItem('otaman_cards', JSON.stringify(cards));
|
||
cardsRebuildSelect();
|
||
cardsRender();
|
||
}
|
||
|
||
function cardsAdd() {
|
||
const name = document.getElementById('cards-name').value.trim();
|
||
const iccid = document.getElementById('cards-iccid').value.trim();
|
||
const kic = document.getElementById('cards-kic').value.trim();
|
||
const kid = document.getElementById('cards-kid').value.trim();
|
||
const spi1 = document.getElementById('cards-spi1').value.trim();
|
||
const spi2 = document.getElementById('cards-spi2').value.trim();
|
||
const tar = document.getElementById('cards-tar').value.trim();
|
||
const cntr = document.getElementById('cards-cntr').value.trim();
|
||
const kicKey = document.getElementById('cards-kic-key').value.trim();
|
||
const kidKey = document.getElementById('cards-kid-key').value.trim();
|
||
if (!name) { alert('Name is required'); return; }
|
||
if (!kic || !kid) { alert('KIc and KID are required'); return; }
|
||
if (!kicKey || !kidKey) { alert('KIc key and KID key are required'); return; }
|
||
if (iccid && cards.some(c => c.iccid === iccid)) { alert('Card with this ICCID already exists'); return; }
|
||
cards.push({
|
||
name: name,
|
||
iccid: iccid,
|
||
kic: kic,
|
||
kid: kid,
|
||
spi1: spi1 || '16',
|
||
spi2: spi2 || '01',
|
||
tar: tar,
|
||
cntr: cntr || '0000000001',
|
||
kicKey: kicKey,
|
||
kidKey: kidKey,
|
||
});
|
||
cardsSave();
|
||
['name','iccid','kic','kid','spi1','spi2','tar','cntr','kic-key','kid-key'].forEach(id => {
|
||
document.getElementById('cards-' + id).value = '';
|
||
});
|
||
}
|
||
|
||
function cardsRemove(i) {
|
||
cards.splice(i, 1);
|
||
cardsSave();
|
||
cardsRender();
|
||
}
|
||
|
||
function cardsRender() {
|
||
const tbody = document.getElementById('cards-tbody');
|
||
if (!cards.length) { tbody.innerHTML = '<tr><td colspan="9" class="py-2 text-gray-400">' + t('No cards defined.') + '</td></tr>'; return; }
|
||
let html = '';
|
||
for (let i = 0; i < cards.length; i++) {
|
||
const c = cards[i];
|
||
html += '<tr class="border-b border-gray-100 dark:border-slate-700">';
|
||
html += '<td class="py-1 px-2 font-medium">' + esc(c.name) + '</td>';
|
||
html += '<td class="py-1 px-2 font-mono text-xs">' + esc(c.iccid) + '</td>';
|
||
html += '<td class="py-1 px-2 font-mono text-xs text-center">' + esc(c.kic) + '</td>';
|
||
html += '<td class="py-1 px-2 font-mono text-xs text-center">' + esc(c.kid) + '</td>';
|
||
html += '<td class="py-1 px-2 font-mono text-xs text-center">' + esc(c.spi1) + '</td>';
|
||
html += '<td class="py-1 px-2 font-mono text-xs text-center">' + esc(c.spi2) + '</td>';
|
||
html += '<td class="py-1 px-2 font-mono text-xs">' + esc(c.tar || '') + '</td>';
|
||
html += '<td class="py-1 px-2 font-mono text-xs">' + esc(c.cntr) + '</td>';
|
||
html += '<td class="py-1 px-2"><button onclick="cardsRemove(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-red-100 text-red-700 hover:bg-red-200">' + t('Remove') + '</button></td>';
|
||
html += '</tr>';
|
||
}
|
||
tbody.innerHTML = html;
|
||
}
|
||
|
||
function cardsRebuildSelect() {
|
||
const sel = document.getElementById('sp-card-sel');
|
||
if (!sel) return;
|
||
let html = '<option value="">' + t('— Select card —') + '</option>';
|
||
for (let i = 0; i < cards.length; i++) {
|
||
html += '<option value="' + i + '">' + esc(cards[i].name) + ' (' + esc(cards[i].iccid) + ')</option>';
|
||
}
|
||
sel.innerHTML = html;
|
||
}
|
||
|
||
function cardsApply(idx) {
|
||
const c = cards[parseInt(idx)];
|
||
if (!c) return;
|
||
document.getElementById('sp-spi1').value = c.spi1;
|
||
document.getElementById('sp-spi2-hex').value = c.spi2;
|
||
document.getElementById('sp-kic-hex').value = c.kic;
|
||
document.getElementById('sp-kid-hex').value = c.kid;
|
||
document.getElementById('sp-cntr').value = c.cntr;
|
||
document.getElementById('sp-kic-key').value = c.kicKey;
|
||
document.getElementById('sp-kid-key').value = c.kidKey;
|
||
updateSp();
|
||
genSp();
|
||
}
|
||
|
||
function cardsExport() {
|
||
const el = document.getElementById('cards-io');
|
||
el.value = JSON.stringify(cards, null, 2);
|
||
el.classList.remove('hidden');
|
||
el.select();
|
||
}
|
||
|
||
function downloadJson(name, obj) {
|
||
const blob = new Blob([JSON.stringify(obj, null, 2)], {type: 'application/json'});
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = name;
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
}
|
||
|
||
function cardsExportFile() {
|
||
downloadJson('otaman-cards.json', cards);
|
||
}
|
||
|
||
async function cardsImport(text) {
|
||
try {
|
||
if (text == null) text = await navigator.clipboard.readText();
|
||
const imported = JSON.parse(text);
|
||
if (!Array.isArray(imported)) throw new Error('Expected an array');
|
||
let added = 0;
|
||
for (const entry of imported) {
|
||
if (!entry.name) continue;
|
||
if (entry.iccid && cards.some(c => c.iccid === entry.iccid)) continue;
|
||
cards.push({
|
||
name: entry.name,
|
||
iccid: entry.iccid,
|
||
kic: entry.kic || '15',
|
||
kid: entry.kid || '15',
|
||
spi1: entry.spi1 || '16',
|
||
spi2: entry.spi2 || '01',
|
||
tar: entry.tar || '',
|
||
cntr: entry.cntr || '0000000001',
|
||
kicKey: entry.kicKey || '',
|
||
kidKey: entry.kidKey || '',
|
||
});
|
||
added++;
|
||
}
|
||
cardsSave();
|
||
cardsRender();
|
||
const el = document.getElementById('cards-io');
|
||
el.value = 'Imported ' + added + ' cards.';
|
||
setTimeout(() => { el.classList.add('hidden'); }, 2000);
|
||
} catch (e) {
|
||
alert('Import error: ' + e.message);
|
||
}
|
||
}
|
||
|
||
function cardsImportFile(input) {
|
||
const file = input.files && input.files[0];
|
||
input.value = '';
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = () => cardsImport(reader.result);
|
||
reader.readAsText(file);
|
||
}
|
||
|
||
function cardsImportField() {
|
||
const el = document.getElementById('cards-io');
|
||
const val = el.value.trim();
|
||
if (!el.classList.contains('hidden') && val.startsWith('[')) { cardsImport(val); return; }
|
||
el.value = '';
|
||
el.placeholder = 'Paste JSON array here, then press Paste & import again';
|
||
el.classList.remove('hidden');
|
||
el.focus();
|
||
}
|
||
|
||
// ===== pysim sub-tabs =====
|
||
let pysimCmdHistory = [];
|
||
let pysimCmdHistIdx = -1;
|
||
let _pysimAutoConnectAttempted = false;
|
||
|
||
function pysimSwitchSubtab(name) {
|
||
document.querySelectorAll('.pysim-subtab').forEach(btn => {
|
||
const isActive = btn.dataset.pysimSub === name;
|
||
btn.classList.toggle('bg-blue-600', isActive);
|
||
btn.classList.toggle('text-white', isActive);
|
||
btn.classList.toggle('bg-gray-200', !isActive);
|
||
btn.classList.toggle('dark:bg-slate-700', !isActive);
|
||
btn.classList.toggle('text-gray-700', !isActive);
|
||
btn.classList.toggle('dark:text-slate-300', !isActive);
|
||
});
|
||
['cmd', 'apdu', 'files', 'custom', 'proactive'].forEach(s => {
|
||
document.getElementById('pysim-sub-' + s).classList.toggle('hidden', s !== name);
|
||
});
|
||
if (name === 'custom') pysimCustomRender();
|
||
if (name === 'proactive') { pysimEventsRender(); pysimProactiveLogRender(); pysimPliRender(); pysimPollStatusInit(); }
|
||
pysimHelpAnchor = {cmd:'pysim-cmdline', apdu:'raw-apdu', files:'file-manager', custom:'custom-files', proactive:'proactive-uicc'}[name] || 'card-reader';
|
||
setHelpAnchor(pysimHelpAnchor);
|
||
}
|
||
|
||
function pysimCmdKeydown(e) {
|
||
if (e.key === 'Enter') {
|
||
const cmd = e.target.value.trim();
|
||
if (cmd) {
|
||
pysimCmdHistory.push(cmd);
|
||
pysimCmdHistIdx = pysimCmdHistory.length;
|
||
}
|
||
pysimExec();
|
||
document.getElementById('pysim-cmd-hint').classList.add('hidden');
|
||
} else if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
if (pysimCmdHistIdx > 0) {
|
||
pysimCmdHistIdx--;
|
||
e.target.value = pysimCmdHistory[pysimCmdHistIdx];
|
||
}
|
||
} else if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
if (pysimCmdHistIdx < pysimCmdHistory.length - 1) {
|
||
pysimCmdHistIdx++;
|
||
e.target.value = pysimCmdHistory[pysimCmdHistIdx];
|
||
} else {
|
||
pysimCmdHistIdx = pysimCmdHistory.length;
|
||
e.target.value = '';
|
||
}
|
||
}
|
||
}
|
||
|
||
let pysimCmdHintTimeout = null;
|
||
|
||
async function pysimCmdHint() {
|
||
clearTimeout(pysimCmdHintTimeout);
|
||
pysimCmdHintTimeout = setTimeout(async () => {
|
||
const input = document.getElementById('pysim-cmd');
|
||
const text = input.value.trim();
|
||
const cmd = text.split(/\s+/)[0];
|
||
if (!cmd || text.includes(' ')) {
|
||
document.getElementById('pysim-cmd-hint').classList.add('hidden');
|
||
return;
|
||
}
|
||
const hintEl = document.getElementById('pysim-cmd-hint');
|
||
try {
|
||
const data = await pysimFetch('/api/help', { cmd });
|
||
if (data.usage) {
|
||
hintEl.textContent = data.usage;
|
||
hintEl.classList.remove('hidden');
|
||
} else {
|
||
hintEl.classList.add('hidden');
|
||
}
|
||
} catch (e) {
|
||
hintEl.classList.add('hidden');
|
||
}
|
||
}, 300);
|
||
}
|
||
|
||
let pysimCmdList = [];
|
||
|
||
async function pysimLoadCommands() {
|
||
try {
|
||
const data = await pysimFetch('/api/commands');
|
||
if (Array.isArray(data)) pysimCmdList = data;
|
||
} catch (e) {}
|
||
}
|
||
|
||
function pysimCmdInput() {
|
||
const input = document.getElementById('pysim-cmd');
|
||
const text = input.value;
|
||
const suggestEl = document.getElementById('pysim-cmd-suggest');
|
||
const hintEl = document.getElementById('pysim-cmd-hint');
|
||
if (text.includes(' ')) {
|
||
suggestEl.classList.add('hidden');
|
||
const cmd = text.split(/\s+/)[0];
|
||
if (cmd) {
|
||
clearTimeout(pysimCmdHintTimeout);
|
||
pysimCmdHintTimeout = setTimeout(async () => {
|
||
try {
|
||
const data = await pysimFetch('/api/help', { cmd });
|
||
if (data.usage) {
|
||
hintEl.textContent = data.usage;
|
||
hintEl.classList.remove('hidden');
|
||
} else {
|
||
hintEl.classList.add('hidden');
|
||
}
|
||
} catch (e) {
|
||
hintEl.classList.add('hidden');
|
||
}
|
||
}, 300);
|
||
return;
|
||
}
|
||
hintEl.classList.add('hidden');
|
||
return;
|
||
}
|
||
hintEl.classList.add('hidden');
|
||
if (!text) { suggestEl.classList.add('hidden'); return; }
|
||
const matches = pysimCmdList.filter(c => c.startsWith(text)).slice(0, 20);
|
||
if (matches.length === 0) { suggestEl.classList.add('hidden'); return; }
|
||
suggestEl.innerHTML = matches.map(c =>
|
||
'<div class="px-2 py-1 cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-700" onclick="pysimCmdSelect(\'' + esc(c) + '\')">' + esc(c) + '</div>'
|
||
).join('');
|
||
suggestEl.classList.remove('hidden');
|
||
}
|
||
|
||
function pysimCmdSelect(cmd) {
|
||
document.getElementById('pysim-cmd').value = cmd;
|
||
document.getElementById('pysim-cmd-suggest').classList.add('hidden');
|
||
document.getElementById('pysim-cmd').focus();
|
||
}
|
||
|
||
// ===== pysim file browser =====
|
||
let pysimFsTreeRoot = null;
|
||
|
||
function getParentSel(node) {
|
||
if (!node || !node.parent) return null;
|
||
const p = node.parent;
|
||
if (p.name === 'MF') return 'MF';
|
||
if (p.name.startsWith('ADF.')) return p.name;
|
||
if (p.fid) return p.fid;
|
||
return p.name;
|
||
}
|
||
|
||
function pysimCustomInject(node) {
|
||
if (!node || !node.fid) return;
|
||
const pfid = node.fid.toLowerCase();
|
||
for (const c of pysimCustomFiles) {
|
||
if (c.parentFid.toLowerCase() !== pfid) continue;
|
||
const existing = (node.children || []).find(ch => ch.fid?.toLowerCase() === c.fid.toLowerCase());
|
||
if (existing) {
|
||
existing.name = c.name;
|
||
existing.custom = true;
|
||
} else {
|
||
node.children = node.children || [];
|
||
node.children.push({
|
||
name: c.name,
|
||
fid: c.fid,
|
||
isDir: c.name.startsWith('DF.') || c.name.startsWith('ADF.'),
|
||
children: null,
|
||
expanded: false,
|
||
exists: null,
|
||
custom: true,
|
||
parent: node,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
async function pysimFsRefresh() {
|
||
const detailEl = document.getElementById('pysim-fs-detail');
|
||
detailEl.classList.add('hidden');
|
||
const data = await pysimFetch('/api/tree', { name: 'MF' });
|
||
pysimFsTreeRoot = {
|
||
name: data.name || 'MF',
|
||
path: data.name || 'MF',
|
||
fid: data.fid || '3F00',
|
||
isDir: true,
|
||
children: null,
|
||
expanded: true,
|
||
parent: null,
|
||
};
|
||
pysimFsTreeRoot.children = (data.children || []).map(c => ({
|
||
name: c.name,
|
||
fid: c.fid,
|
||
isDir: c.isDir,
|
||
aid: c.aid,
|
||
children: null,
|
||
expanded: false,
|
||
exists: true,
|
||
parent: pysimFsTreeRoot,
|
||
}));
|
||
pysimFsTreeRoot.exists = true;
|
||
pysimCustomInject(pysimFsTreeRoot);
|
||
pysimFsRenderTree();
|
||
}
|
||
|
||
async function pysimFsLoadChildren(node) {
|
||
if (node.children) return;
|
||
try {
|
||
let body = { name: node.name, fid: node.fid };
|
||
const parentSel = getParentSel(node);
|
||
if (parentSel) body.parent_sel = parentSel;
|
||
let data = await pysimFetch('/api/tree', body);
|
||
if (!data || data.exists === false) {
|
||
body = { name: node.name, fid: node.fid };
|
||
data = await pysimFetch('/api/tree', body);
|
||
}
|
||
if (!data || data.exists === false) {
|
||
throw new Error('File not found');
|
||
}
|
||
node.children = (data.children || []).map(c => ({
|
||
name: c.name,
|
||
fid: c.fid,
|
||
isDir: c.isDir,
|
||
aid: c.aid,
|
||
children: null,
|
||
expanded: false,
|
||
exists: true,
|
||
parent: node,
|
||
}));
|
||
node.exists = true;
|
||
pysimCustomInject(node);
|
||
} catch (e) {
|
||
node.exists = false;
|
||
node.children = [];
|
||
pysimFsRenderTree();
|
||
}
|
||
}
|
||
|
||
function pysimFsRenderTree() {
|
||
const el = document.getElementById('pysim-fs-tree');
|
||
if (!pysimFsTreeRoot) { el.innerHTML = '(loading)'; return; }
|
||
el.innerHTML = pysimFsRenderNode(pysimFsTreeRoot, 0);
|
||
}
|
||
|
||
function pysimFsRenderNode(node, depth) {
|
||
if (!node) return '';
|
||
const pad = ' '.repeat(depth * 4);
|
||
const isDir = node.isDir;
|
||
const exists = node.exists !== false;
|
||
const verified = node.exists !== null;
|
||
const color = verified ? (exists ? (isDir ? 'text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-slate-300') : 'text-red-400 dark:text-red-400') : 'text-gray-500 dark:text-slate-400';
|
||
const italic = node.custom ? ' italic' : '';
|
||
const fidStr = node.fid ? ' <span class="text-gray-400 dark:text-slate-500">(' + node.fid.toUpperCase() + ')</span>' : '';
|
||
const aidStr = node.aid ? ' <span class="text-gray-400 dark:text-slate-500">[' + node.aid + ']</span>' : '';
|
||
let html = '<div class="' + color + italic + '">';
|
||
html += pad;
|
||
if (verified && !exists) {
|
||
html += '✗ ';
|
||
}
|
||
if (isDir) {
|
||
if ((verified && exists) || !verified) {
|
||
const toggle = node.children && node.expanded ? '▼' : '▶';
|
||
html += '<span class="cursor-pointer select-none' + italic + '" onclick="pysimFsToggleDir(\'' + esc(node.name) + '\')">' + toggle + '</span> ';
|
||
html += '<span class="cursor-pointer hover:underline' + italic + '" onclick="pysimFsToggleDir(\'' + esc(node.name) + '\')">' + esc(node.name) + fidStr + aidStr + '</span>';
|
||
} else {
|
||
html += esc(node.name) + fidStr + aidStr + '</div>';
|
||
return html;
|
||
}
|
||
} else {
|
||
html += '<span class="cursor-pointer hover:underline ml-4' + italic + '"' +
|
||
((verified && exists) ? ' onclick="pysimFsClickFile(\'' + esc(node.name) + '\')"' : (!verified ? ' onclick="pysimFsClickFile(\'' + esc(node.name) + '\')"' : '')) + '>' + esc(node.name) + fidStr + aidStr + '</span>';
|
||
}
|
||
html += '</div>';
|
||
if (isDir && node.children && node.expanded) {
|
||
for (const child of node.children) {
|
||
html += pysimFsRenderNode(child, depth + 1);
|
||
}
|
||
}
|
||
return html;
|
||
}
|
||
|
||
function pysimFsFindNode(name, root) {
|
||
if (!root) return null;
|
||
if (root.name === name) return root;
|
||
if (root.children) {
|
||
for (const c of root.children) {
|
||
const found = pysimFsFindNode(name, c);
|
||
if (found) return found;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function pysimFsToggleDir(name) {
|
||
const node = pysimFsFindNode(name, pysimFsTreeRoot);
|
||
if (!node || !node.isDir) return;
|
||
if (!node.children) {
|
||
await pysimFsLoadChildren(node);
|
||
}
|
||
node.expanded = !node.expanded;
|
||
pysimFsRenderTree();
|
||
if (node.expanded) pysimRefresh();
|
||
}
|
||
|
||
async function pysimFsClickFile(name) {
|
||
pysimFsResetEdit();
|
||
pysimFsSetMode('raw');
|
||
const node = pysimFsFindNode(name, pysimFsTreeRoot);
|
||
let body = { name: name, fid: node ? node.fid : null };
|
||
const parentSel = getParentSel(node);
|
||
if (parentSel) body.parent_sel = parentSel;
|
||
let sel = await pysimFetch('/api/select', body);
|
||
if (!sel || sel.exists === false) {
|
||
body = { name: name, fid: node ? node.fid : null };
|
||
sel = await pysimFetch('/api/select', body);
|
||
}
|
||
const exists = sel && sel.exists !== false;
|
||
if (node) {
|
||
node.exists = exists;
|
||
if (!exists) node.children = [];
|
||
pysimFsRenderTree();
|
||
}
|
||
const ft = exists ? sel.file_type : null;
|
||
const label = ({ transparent: 'TR', linear_fixed: 'LF', cyclic: 'CY', ber_tlv: 'BT', df: 'DF' })[ft] || '';
|
||
const prefix = exists ? '' : '✗ ';
|
||
document.getElementById('pysim-fs-filename').textContent = prefix + name + (label ? ' [' + label + ']' : '');
|
||
document.getElementById('pysim-fs-content').innerHTML = '';
|
||
document.getElementById('pysim-fs-status').textContent = exists ? '' : '✗ File not found on card';
|
||
if (exists) {
|
||
document.getElementById('pysim-fs-detail').classList.remove('hidden');
|
||
} else {
|
||
document.getElementById('pysim-fs-detail').classList.add('hidden');
|
||
}
|
||
pysimRefresh();
|
||
}
|
||
|
||
async function pysimFsRead() {
|
||
const rawName = document.getElementById('pysim-fs-filename').textContent.replace(/ \[.*?\]$/, '').replace(/^✗ /, '');
|
||
const out = document.getElementById('pysim-fs-content');
|
||
const statusEl = document.getElementById('pysim-fs-status');
|
||
out.innerHTML = '';
|
||
statusEl.textContent = 'Loading...';
|
||
try {
|
||
const node = pysimFsFindNode(rawName, pysimFsTreeRoot);
|
||
let body = { name: rawName, fid: node ? node.fid : null, mode: pysimFsDecodedMode ? 'decoded' : 'raw' };
|
||
const parentSel = getParentSel(node);
|
||
if (parentSel) body.parent_sel = parentSel;
|
||
let data = await pysimFetch('/api/read', body);
|
||
if (!data.success) {
|
||
body = { name: rawName, fid: node ? node.fid : null, mode: pysimFsDecodedMode ? 'decoded' : 'raw' };
|
||
data = await pysimFetch('/api/read', body);
|
||
}
|
||
if (!data.success) {
|
||
statusEl.textContent = 'SW: ' + (data.sw || '?') + ' — ' + (data.error || 'Error');
|
||
return;
|
||
}
|
||
statusEl.textContent = 'SW: ' + data.sw + ' OK';
|
||
if (data.decoded) {
|
||
out.innerHTML = '<pre class="text-xs leading-relaxed">' + esc(JSON.stringify(data.decoded, null, 2)) + '</pre>';
|
||
} else if (data.records) {
|
||
let html = '';
|
||
for (const r of data.records) {
|
||
html += '<div class="flex items-center gap-1 mb-1">' +
|
||
'<input type="checkbox" class="pysim-fs-record-cb shrink-0 hidden">' +
|
||
'<span class="text-xs w-8 text-gray-500 dark:text-slate-400 shrink-0">' + r.num + '</span>' +
|
||
'<input class="flex-1 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-white dark:bg-slate-800 text-gray-800 dark:text-slate-200" style="overflow-x:auto;min-width:100px" value="' + esc(r.data) + '" readonly>' +
|
||
'</div>';
|
||
}
|
||
out.innerHTML = html || '(no records)';
|
||
} else {
|
||
out.innerHTML = '<textarea rows="8" readonly class="w-full font-mono text-xs border-0 bg-transparent resize-none">' + esc(data.data || '(no output)') + '</textarea>';
|
||
}
|
||
} catch (e) {
|
||
statusEl.textContent = 'Error: ' + e.message;
|
||
}
|
||
}
|
||
|
||
async function pysimFsSelect() {
|
||
const name = document.getElementById('pysim-fs-filename').textContent;
|
||
const node = pysimFsFindNode(name, pysimFsTreeRoot);
|
||
if (node && node.fid) {
|
||
await pysimFetch('/api/command', { cmd: 'select ' + node.fid });
|
||
} else {
|
||
await pysimFetch('/api/command', { cmd: 'select ' + name });
|
||
}
|
||
pysimFsRefresh();
|
||
pysimRefresh();
|
||
}
|
||
|
||
let pysimFsEditMode = false;
|
||
let pysimFsEditData = null;
|
||
|
||
function pysimFsEdit() {
|
||
pysimFsEditMode = true;
|
||
document.getElementById('pysim-fs-read-btn').classList.add('hidden');
|
||
document.getElementById('pysim-fs-edit-btn').classList.add('hidden');
|
||
document.getElementById('pysim-fs-save-btn').classList.remove('hidden');
|
||
document.getElementById('pysim-fs-cancel-btn').classList.remove('hidden');
|
||
const out = document.getElementById('pysim-fs-content');
|
||
out.querySelectorAll('textarea, input:not([type=checkbox])').forEach(el => el.removeAttribute('readonly'));
|
||
out.querySelectorAll('.pysim-fs-record-cb').forEach(cb => cb.classList.remove('hidden'));
|
||
out.querySelectorAll('input:not([type=checkbox])').forEach(el => {
|
||
el.addEventListener('input', function() {
|
||
const cb = this.closest('div')?.querySelector('.pysim-fs-record-cb');
|
||
if (cb) cb.checked = true;
|
||
});
|
||
});
|
||
pysimFsEditData = out.innerHTML;
|
||
}
|
||
|
||
function pysimFsCancel() {
|
||
pysimFsEditMode = false;
|
||
document.getElementById('pysim-fs-read-btn').classList.remove('hidden');
|
||
document.getElementById('pysim-fs-edit-btn').classList.remove('hidden');
|
||
document.getElementById('pysim-fs-save-btn').classList.add('hidden');
|
||
document.getElementById('pysim-fs-cancel-btn').classList.add('hidden');
|
||
const out = document.getElementById('pysim-fs-content');
|
||
if (pysimFsEditData) out.innerHTML = pysimFsEditData;
|
||
pysimFsEditData = null;
|
||
}
|
||
|
||
async function pysimFsSave() {
|
||
const rawName = document.getElementById('pysim-fs-filename').textContent.replace(/ \[.*?\]$/, '').replace(/^✗ /, '');
|
||
const statusEl = document.getElementById('pysim-fs-status');
|
||
const out = document.getElementById('pysim-fs-content');
|
||
const node = pysimFsFindNode(rawName, pysimFsTreeRoot);
|
||
statusEl.textContent = 'Saving...';
|
||
try {
|
||
const textareas = out.querySelectorAll('textarea');
|
||
if (textareas.length === 1) {
|
||
// Transparent file
|
||
const body = { name: rawName, fid: node ? node.fid : null, data: textareas[0].value.replace(/[^0-9a-fA-F]/g, '') };
|
||
const parentSel = getParentSel(node);
|
||
if (parentSel) body.parent_sel = parentSel;
|
||
const data = await pysimFetch('/api/write', body);
|
||
if (data.success) {
|
||
statusEl.textContent = 'SW: ' + data.sw + ' OK';
|
||
pysimFsCancel();
|
||
pysimFsRead();
|
||
} else {
|
||
statusEl.textContent = 'SW: ' + (data.sw || '?') + ' — ' + (data.error || 'Error');
|
||
}
|
||
} else {
|
||
// Record file
|
||
const inputs = out.querySelectorAll('input:not([type=checkbox])');
|
||
let wroteAny = false;
|
||
for (const inp of inputs) {
|
||
const row = inp.closest('div');
|
||
const cb = row?.querySelector('.pysim-fs-record-cb');
|
||
if (cb && !cb.checked) continue;
|
||
wroteAny = true;
|
||
const num = row?.querySelector('span')?.textContent;
|
||
if (num) {
|
||
const body = { name: rawName, fid: node ? node.fid : null, data: inp.value.replace(/[^0-9a-fA-F]/g, ''), record_nr: parseInt(num) };
|
||
const parentSel = getParentSel(node);
|
||
if (parentSel) body.parent_sel = parentSel;
|
||
const data = await pysimFetch('/api/write', body);
|
||
if (!data.success) {
|
||
statusEl.textContent = 'SW: ' + (data.sw || '?') + ' — ' + (data.error || 'Error') + ' (record ' + num + ')';
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
if (!wroteAny) {
|
||
statusEl.textContent = 'No records selected — check the ones you want to update';
|
||
return;
|
||
}
|
||
statusEl.textContent = 'SW: 9000 OK';
|
||
pysimFsCancel();
|
||
pysimFsRead();
|
||
}
|
||
} catch (e) {
|
||
statusEl.textContent = 'Error: ' + e.message;
|
||
}
|
||
}
|
||
|
||
function pysimFsSetMode(mode) {
|
||
pysimFsDecodedMode = mode === 'dec';
|
||
document.querySelectorAll('.pysim-mode-pill').forEach(el => {
|
||
const active = el.dataset.mode === mode;
|
||
el.classList.toggle('bg-blue-600', active);
|
||
el.classList.toggle('text-white', active);
|
||
el.classList.toggle('bg-gray-200', !active);
|
||
el.classList.toggle('dark:bg-slate-700', !active);
|
||
el.classList.toggle('text-gray-700', !active);
|
||
el.classList.toggle('dark:text-slate-300', !active);
|
||
});
|
||
}
|
||
|
||
function pysimFsResetEdit() {
|
||
pysimFsEditMode = false;
|
||
pysimFsEditData = null;
|
||
document.getElementById('pysim-fs-read-btn').classList.remove('hidden');
|
||
document.getElementById('pysim-fs-edit-btn').classList.remove('hidden');
|
||
document.getElementById('pysim-fs-save-btn').classList.add('hidden');
|
||
document.getElementById('pysim-fs-cancel-btn').classList.add('hidden');
|
||
}
|
||
|
||
// ===== proactive UICC events =====
|
||
const EVENT_NAMES = {
|
||
0x00: 'MT call', 0x01: 'Call connected', 0x02: 'Call disconnected',
|
||
0x03: 'Location status', 0x04: 'User activity', 0x05: 'Idle screen available',
|
||
0x06: 'Card reader status', 0x07: 'Language selection',
|
||
0x08: 'Browser termination', 0x09: 'Data available',
|
||
0x0A: 'Channel status', 0x0B: 'Access Technology Change',
|
||
0x0C: 'Display parameters changed', 0x0D: 'Local connection',
|
||
0x0E: 'Network Search Mode Change', 0x0F: 'Browsing status',
|
||
0x10: 'Frames Information Change', 0x11: 'I-WLAN Access Status',
|
||
0x12: 'Network Rejection', 0x13: 'HCI Connectivity',
|
||
0x14: 'Change of UICC Access', 0x15: 'CSG Cell Change',
|
||
0x16: 'Contactless state request', 0x17: 'Profile Container',
|
||
0x18: 'LTE D2D Discovery Monitoring', 0x19: 'LTE D2D Communication Monitoring',
|
||
0x1A: 'LTE D2D Announcement Response', 0x1B: 'LTE D2D Revocation',
|
||
0x1C: 'LTE D2D Application Port', 0x1D: 'LTE D2D Security Recovery',
|
||
0x1E: 'Off-net Emergency Call', 0x1F: 'ECall Over IMS',
|
||
0x20: 'EARFCN Update', 0x21: 'SCEF Channel Status',
|
||
};
|
||
|
||
const REJECTION_CAUSES = [
|
||
{v:2,l:'02 \u2014 IMSI unknown in HLR/HSS'},{v:3,l:'03 \u2014 Illegal UE / MS'},
|
||
{v:5,l:'05 \u2014 IMEI / PEI not accepted'},{v:6,l:'06 \u2014 Illegal ME'},
|
||
{v:7,l:'07 \u2014 Services not allowed'},{v:8,l:'08 \u2014 Services + non-services not allowed'},
|
||
{v:9,l:'09 \u2014 UE identity cannot be derived'},{v:10,l:'0A \u2014 Implicitly detached / de-registered'},
|
||
{v:11,l:'0B \u2014 PLMN not allowed'},{v:12,l:'0C \u2014 Location / Tracking Area not allowed'},
|
||
{v:13,l:'0D \u2014 Roaming not allowed in this area'},{v:14,l:'0E \u2014 Services not allowed in this PLMN'},
|
||
{v:15,l:'0F \u2014 No Suitable Cells'},{v:16,l:'10 \u2014 MSC temporarily not reachable'},
|
||
{v:17,l:'11 \u2014 Network failure'},
|
||
{v:18,l:'12 \u2014 CS domain not avail. (EMM) / MAC fail (GMM)'},
|
||
{v:19,l:'13 \u2014 ESM failure (EMM) / Synch failure (GMM)'},
|
||
{v:20,l:'14 \u2014 MAC failure (EMM) / Congestion (GMM)'},
|
||
{v:21,l:'15 \u2014 Synch failure (EMM)'},{v:22,l:'16 \u2014 Congestion / PLMN rejected perm. (LU)'},
|
||
{v:23,l:'17 \u2014 UE security capabilities mismatch'},{v:24,l:'18 \u2014 Security mode rejected, unspecified'},
|
||
{v:25,l:'19 \u2014 Not authorized for this CSG'},{v:26,l:'1A \u2014 Non-3GPP/5G auth unacceptable'},
|
||
{v:27,l:'1B \u2014 N1 mode not allowed (5GMM)'},{v:28,l:'1C \u2014 Restricted service area (5GMM)'},
|
||
{v:31,l:'1F \u2014 Redirection to EPC required (5GMM)'},
|
||
{v:32,l:'20 \u2014 Service option not supported (LU)'},
|
||
{v:33,l:'21 \u2014 Requested service option not subscribed (LU)'},
|
||
{v:34,l:'22 \u2014 Service option temporarily out of order (LU)'},
|
||
{v:35,l:'23 \u2014 Requested service option not authorized (EMM)'},
|
||
{v:38,l:'26 \u2014 Call cannot be identified (LU)'},
|
||
{v:39,l:'27 \u2014 CS service temporarily not available (EMM)'},
|
||
{v:40,l:'28 \u2014 No EPS bearer context activated (EMM)'},
|
||
{v:42,l:'2A \u2014 Severe network failure (EMM)'},{v:43,l:'2B \u2014 LADN not available (5GMM)'},
|
||
{v:48,l:'30 \u2014 Retry upon entry into new cell (LU)'},
|
||
{v:62,l:'3E \u2014 No network slices available (5GMM)'},
|
||
{v:65,l:'41 \u2014 Maximum PDU sessions reached (5GMM)'},
|
||
{v:67,l:'43 \u2014 Insufficient resources for slice/DNN (5GMM)'},
|
||
{v:69,l:'45 \u2014 Insufficient resources for slice (5GMM)'},
|
||
{v:71,l:'47 \u2014 ngKSI already in use (5GMM)'},
|
||
{v:72,l:'48 \u2014 Non-3GPP access to 5GCN not allowed (5GMM)'},
|
||
{v:73,l:'49 \u2014 Serving network not authorized (5GMM)'},
|
||
{v:74,l:'4A \u2014 Temporarily not authorized for SNPN (5GMM)'},
|
||
{v:75,l:'4B \u2014 Permanently not authorized for SNPN (5GMM)'},
|
||
{v:76,l:'4C \u2014 Not authorized for this CAG (5GMM)'},
|
||
{v:90,l:'5A \u2014 Payload was not forwarded (5GMM)'},
|
||
{v:91,l:'5B \u2014 DNN not supported/subscribed in slice (5GMM)'},
|
||
{v:92,l:'5C \u2014 Insufficient UP resources for PDU session (5GMM)'},
|
||
{v:95,l:'5F \u2014 Semantically incorrect message'},{v:96,l:'60 \u2014 Invalid mandatory information'},
|
||
{v:97,l:'61 \u2014 Message type non-existent/not implemented'},
|
||
{v:98,l:'62 \u2014 Message type not compatible with protocol state'},
|
||
{v:99,l:'63 \u2014 IE non-existent/not implemented'},
|
||
{v:100,l:'64 \u2014 Conditional IE error'},{v:101,l:'65 \u2014 Message not compatible with protocol state'},
|
||
{v:111,l:'6F \u2014 Protocol error, unspecified'},
|
||
];
|
||
|
||
const EVENT_FORMS = {
|
||
0x00: { title: 'MT call', note: 'not_yet' },
|
||
0x01: { title: 'Call connected', note: 'not_yet' },
|
||
0x02: { title: 'Call disconnected', note: 'not_yet' },
|
||
0x03: { title: 'Location status',
|
||
fields: [
|
||
{ id: 'status', label: 'Service Status', type: 'select',
|
||
opts: [{v:0,l:'Normal service'},{v:1,l:'Limited service'},{v:2,l:'No service'}] }
|
||
],
|
||
build(v) { return '9B01' + (v.status|0).toString(16).padStart(2,'0'); }
|
||
},
|
||
0x04: null,
|
||
0x05: null,
|
||
0x06: { title: 'Card reader status',
|
||
fields: [
|
||
{ id: 'status', label: 'Status', type: 'select',
|
||
opts: [{v:0,l:'Card reader removed'},{v:1,l:'Card reader inserted'}] }
|
||
],
|
||
build(v) { return '' + (v.status|0).toString(16).padStart(2,'0'); }
|
||
},
|
||
0x07: { title: 'Language selection',
|
||
fields: [
|
||
{ id: 'lang', label: 'Language code', type: 'text', placeholder: 'ISO 639, e.g. en, ru', pattern: '[a-zA-Z]{2,3}' }
|
||
],
|
||
build(v) { const b = new TextEncoder().encode(v.lang||'en'); return b.length.toString(16).padStart(2,'0') + bytesToHex(Array.from(b)); }
|
||
},
|
||
0x08: { title: 'Browser termination', note: 'not_yet' },
|
||
0x09: null,
|
||
0x0A: { title: 'Channel status', note: 'not_yet' },
|
||
0x0B: { title: 'Access Technology Change',
|
||
fields: [
|
||
{ id: 'tech', label: 'Access Technology', type: 'select',
|
||
opts: [{v:0,l:'GSM'},{v:1,l:'GSM Compact'},{v:3,l:'UTRAN'},{v:4,l:'GSM w/EGPRS'},{v:5,l:'UTRAN w/HSDPA'},{v:6,l:'UTRAN w/HSUPA'},{v:7,l:'UTRAN w/HSPA+'},{v:8,l:'E-UTRAN'},{v:9,l:'E-UTRAN (4G)'},{v:10,l:'5G NR'},{v:11,l:'5G NR (NSA)'},{v:12,l:'Satellite'}] }
|
||
],
|
||
build(v) { return 'BF01' + (v.tech|0).toString(16).padStart(2,'0'); }
|
||
},
|
||
0x0C: null,
|
||
0x0D: null,
|
||
0x0E: null,
|
||
0x0F: null,
|
||
0x10: null,
|
||
0x11: { title: 'I-WLAN Access Status', note: 'not_yet' },
|
||
0x12: { title: 'Network Rejection',
|
||
fields: [
|
||
{ id: 'reg_type', label: 'Registration type', type: 'select', onchange: 'updateNetRejectionFields()',
|
||
opts: [
|
||
{v:0x00,l:'Normal Location Updating',g:'lu'},{v:0x01,l:'Periodic Location Updating',g:'lu'},
|
||
{v:0x02,l:'IMSI Attach',g:'lu'},{v:0x03,l:'GPRS Attach',g:'gprs'},
|
||
{v:0x04,l:'Combined GPRS/IMSI Attach',g:'gprs'},{v:0x05,l:'RA Updating',g:'gprs'},
|
||
{v:0x06,l:'Combined RA/LA Updating',g:'gprs'},{v:0x07,l:'Combined RA/LA Updating w/ IMSI Attach',g:'gprs'},
|
||
{v:0x08,l:'Periodic RA Updating',g:'gprs'},{v:0x09,l:'EPS Attach (EMM)',g:'eps'},
|
||
{v:0x0A,l:'Combined EPS/IMSI Attach (EMM)',g:'eps'},{v:0x0B,l:'TA Updating (TAU)',g:'eps'},
|
||
{v:0x0C,l:'Combined TA/LA Updating (TAU)',g:'eps'},{v:0x0D,l:'Combined TA/LA Updating w/ IMSI Attach (TAU)',g:'eps'},
|
||
{v:0x0E,l:'Periodic TA Updating (TAU)',g:'eps'},{v:0x0F,l:'Initial Registration (5GS)',g:'eps'},
|
||
{v:0x10,l:'Mobility Registration Updating (5GS)',g:'eps'},{v:0x11,l:'Periodic Registration Updating (5GS)',g:'eps'},
|
||
]},
|
||
{ id: 'mcc', label: 'MCC', type: 'text', pattern: '\\d{3}', placeholder: 'e.g. 250', g: 'lu,gprs,eps' },
|
||
{ id: 'mnc', label: 'MNC', type: 'text', pattern: '\\d{2,3}', placeholder: 'e.g. 01', g: 'lu,gprs,eps' },
|
||
{ id: 'lac', label: 'LAC', type: 'text', pattern: '[0-9A-Fa-f]{4}', placeholder: 'hex, e.g. 00FF', g: 'lu,gprs' },
|
||
{ id: 'rac', label: 'RAC', type: 'text', pattern: '[0-9A-Fa-f]{2}', placeholder: 'hex, e.g. 01', g: 'gprs' },
|
||
{ id: 'tac', label: 'TAC', type: 'text', pattern: '[0-9A-Fa-f]{4,6}', placeholder: '4 (E-UTRAN) or 6 (NR) hex chars', g: 'eps' },
|
||
{ id: 'access_tech', label: 'Access Technology', type: 'select',
|
||
opts: [{v:0x00,l:'GSM'},{v:0x03,l:'UTRAN'},{v:0x08,l:'E-UTRAN'},{v:0x0A,l:'NG-RAN'},{v:0x0B,l:'Satellite NG-RAN'},{v:0x0C,l:'Satellite E-UTRAN'}], g: 'lu,gprs,eps' },
|
||
{ id: 'cause', label: 'Rejection cause code', type: 'select', opts: REJECTION_CAUSES, g: 'lu,gprs,eps' },
|
||
{ id: 'ext_cause', label: 'Extended EMM cause (optional)', type: 'number', min: '0', max: '255', placeholder: '0–255', g: 'eps' },
|
||
],
|
||
build(v) {
|
||
const rt = parseInt(v.reg_type) || 0;
|
||
const g = rt <= 0x02 ? 'lu' : rt <= 0x08 ? 'gprs' : 'eps';
|
||
let d = 'F401' + rt.toString(16).padStart(2,'0');
|
||
d += 'BF01' + (parseInt(v.access_tech)|0).toString(16).padStart(2,'0');
|
||
d += 'F501' + (parseInt(v.cause) || 2).toString(16).padStart(2,'0');
|
||
const pm = encPlmn(v.mcc||'250',v.mnc||'01');
|
||
if (g === 'lu') {
|
||
d += '9305' + pm + ((v.lac||'0000').toUpperCase());
|
||
} else if (g === 'gprs') {
|
||
d += 'F306' + pm + ((v.lac||'0000').toUpperCase()) + ((v.rac||'01').toUpperCase());
|
||
} else {
|
||
const tc = (v.tac||'0001').toUpperCase();
|
||
d += '7D0' + (3 + tc.length/2).toString(16) + pm + tc;
|
||
if (v.ext_cause !== '' && v.ext_cause !== undefined) {
|
||
d += '5701' + (parseInt(v.ext_cause)|0).toString(16).padStart(2,'0');
|
||
}
|
||
}
|
||
return d.toUpperCase();
|
||
}
|
||
},
|
||
0x13: null,
|
||
0x14: { title: 'Change of UICC Access',
|
||
fields: [{ id: 'tech', label: 'Access Technology', type: 'select',
|
||
opts: [{v:0,l:'GSM'},{v:3,l:'UTRAN'},{v:8,l:'E-UTRAN'},{v:10,l:'NG-RAN'}] }],
|
||
build(v) { return 'BF01' + (v.tech|0).toString(16).padStart(2,'0'); }
|
||
},
|
||
0x15: { title: 'CSG Cell Change', note: 'not_yet' },
|
||
0x16: null,
|
||
0x17: null,
|
||
};
|
||
|
||
const PLI_QUALIFIERS = [
|
||
{code:'00',name:'Location Info (MCC, MNC, LAC/TAC, Cell ID)'},{code:'01',name:'IMEI'},
|
||
{code:'02',name:'Network Measurement results'},{code:'03',name:'Date, time and time zone'},
|
||
{code:'04',name:'Language setting'},{code:'05',name:'Timing Advance'},
|
||
{code:'06',name:'Access Technology (single)'},{code:'08',name:'IMEISV'},
|
||
{code:'09',name:'Search Mode'},{code:'0A',name:'Battery charge state'},
|
||
{code:'0C',name:'Current WSID'},{code:'0D',name:'Broadcast Network info'},
|
||
{code:'0E',name:'Multiple Access Technologies'},{code:'0F',name:'Location Info (multi-RAT)'},
|
||
{code:'10',name:'NMR (multi-RAT)'},{code:'11',name:'CSG ID list + HNB name'},
|
||
{code:'12',name:'H(e)NB IP address'},{code:'13',name:'H(e)NB surrounding macrocells'},
|
||
{code:'14',name:'Current WLAN identifier'},{code:'15',name:'Slices information'},
|
||
{code:'16',name:'CAG information list'},{code:'17',name:'Rejected slices information'},
|
||
];
|
||
|
||
async function pysimEventsRender() {
|
||
const el = document.getElementById('pysim-event-list');
|
||
el.innerHTML = '<span class="text-gray-400">' + t('Loading...') + '</span>';
|
||
try {
|
||
const events = await pysimFetch('/api/events');
|
||
if (!events || !events.length) {
|
||
el.innerHTML = '<span class="text-gray-400">' + t('No events configured.') + '</span>';
|
||
return;
|
||
}
|
||
let html = '';
|
||
events.forEach(e => {
|
||
const name = EVENT_NAMES[e] || ('Event 0x' + e.toString(16).padStart(2, '0').toUpperCase());
|
||
html += '<div class="flex items-center gap-2 py-0.5"><span class="w-6 text-right text-gray-400">'
|
||
+ e.toString(16).padStart(2, '0').toUpperCase()
|
||
+ '</span> ' + esc(name)
|
||
+ '<button onclick="pysimEventSendForm(' + e + ')" class="ml-auto px-2 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700">' + t('Send') + '</button></div>';
|
||
});
|
||
el.innerHTML = html;
|
||
} catch (e) {
|
||
el.innerHTML = '<span class="text-red-500">Error: ' + esc(e.message) + '</span>';
|
||
}
|
||
}
|
||
|
||
let _eventFormType = null;
|
||
|
||
function pysimEventSendForm(eventType) {
|
||
const modal = document.getElementById('event-send-modal');
|
||
const title = document.getElementById('event-send-title');
|
||
const body = document.getElementById('event-send-body');
|
||
const err = document.getElementById('event-send-error');
|
||
const result = document.getElementById('event-send-result');
|
||
const btn = document.getElementById('event-send-btn');
|
||
err.classList.add('hidden');
|
||
result.classList.add('hidden');
|
||
err.textContent = '';
|
||
result.textContent = '';
|
||
|
||
const name = EVENT_NAMES[eventType] || ('0x' + eventType.toString(16).padStart(2,'0'));
|
||
const cfg = EVENT_FORMS[eventType];
|
||
_eventFormType = eventType;
|
||
|
||
if (!cfg) {
|
||
title.textContent = name + ' (' + t('Send') + ')';
|
||
body.innerHTML = '<p class="text-sm text-gray-600 dark:text-slate-400">' + t('Send') + ' <span class="font-mono">' + esc(name) + '</span> ' + t('event notification to the card?') + '</p>';
|
||
btn.textContent = t('Send');
|
||
modal.classList.remove('hidden');
|
||
return;
|
||
}
|
||
if (cfg.note === 'not_yet') {
|
||
title.textContent = name;
|
||
body.innerHTML = '<p class="text-sm text-gray-500 dark:text-slate-400">' + t('This event type is not yet supported.') + '</p>';
|
||
btn.classList.add('hidden');
|
||
} else {
|
||
title.textContent = name + ' (' + t('Send') + ')';
|
||
let html = '';
|
||
cfg.fields.forEach(f => {
|
||
const rg = f.g ? f.g.split(',') : null;
|
||
html += '<div class="event-field" data-groups="' + (f.g||'') + '" data-id="' + f.id + '">';
|
||
html += '<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300">' + t(f.label) + '</label>';
|
||
if (f.type === 'select') {
|
||
html += '<select id="ev-f-' + f.id + '"' + (f.onchange ? ' onchange="' + f.onchange + '"' : '') + ' class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">';
|
||
f.opts.forEach(o => html += '<option value="' + o.v + '"' + (o.g ? ' data-gr="' + o.g + '"' : '') + '>' + esc(o.l) + '</option>');
|
||
html += '</select>';
|
||
} else {
|
||
const attrs = [];
|
||
if (f.pattern) attrs.push('pattern="' + f.pattern + '"');
|
||
if (f.placeholder) attrs.push('placeholder="' + f.placeholder + '"');
|
||
if (f.min !== undefined) attrs.push('min="' + f.min + '" max="' + f.max + '"');
|
||
html += '<input id="ev-f-' + f.id + '" type="' + f.type + '" ' + attrs.join(' ') + ' class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono">';
|
||
}
|
||
html += '</div>';
|
||
});
|
||
body.innerHTML = html;
|
||
btn.classList.remove('hidden');
|
||
if (eventType === 0x12) updateNetRejectionFields();
|
||
}
|
||
modal.classList.remove('hidden');
|
||
}
|
||
|
||
function updateNetRejectionFields() {
|
||
const sel = document.getElementById('ev-f-reg_type');
|
||
if (!sel) return;
|
||
const opt = sel.options[sel.selectedIndex];
|
||
const g = opt ? opt.getAttribute('data-gr') || 'lu' : 'lu';
|
||
document.querySelectorAll('#event-send-body .event-field').forEach(div => {
|
||
const groups = div.getAttribute('data-groups') || '';
|
||
const show = groups === '' || groups.indexOf(g) >= 0;
|
||
div.style.display = show ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
async function pysimEventSend() {
|
||
const err = document.getElementById('event-send-error');
|
||
const result = document.getElementById('event-send-result');
|
||
const btn = document.getElementById('event-send-btn');
|
||
err.classList.add('hidden');
|
||
result.classList.add('hidden');
|
||
|
||
const cfg = EVENT_FORMS[_eventFormType];
|
||
let eventData = null;
|
||
|
||
if (cfg && cfg.build) {
|
||
const values = {};
|
||
let valid = true;
|
||
cfg.fields.forEach(f => {
|
||
const el = document.getElementById('ev-f-' + f.id);
|
||
if (!el || el.closest('.event-field').style.display === 'none') return;
|
||
const v = el.value.trim();
|
||
values[f.id] = v;
|
||
if (f.pattern && v && !new RegExp('^' + f.pattern + '$').test(v)) {
|
||
err.textContent = t('Invalid') + ': ' + t(f.label);
|
||
err.classList.remove('hidden');
|
||
valid = false;
|
||
}
|
||
});
|
||
if (!valid) return;
|
||
try {
|
||
eventData = cfg.build(values);
|
||
} catch (e) {
|
||
err.textContent = 'Error: ' + e.message;
|
||
err.classList.remove('hidden');
|
||
return;
|
||
}
|
||
}
|
||
|
||
btn.disabled = true;
|
||
btn.textContent = t('Sending...');
|
||
try {
|
||
const resp = await pysimFetch('/api/event-send', { event_type: _eventFormType, event_data: eventData });
|
||
if (resp.sw === '9000') {
|
||
result.textContent = (eventData ? t('Event data sent.') : t('Event sent.')) + ' SW: ' + resp.sw;
|
||
result.classList.remove('hidden');
|
||
} else {
|
||
result.textContent = 'SW: ' + resp.sw;
|
||
result.classList.remove('hidden');
|
||
result.classList.add('text-amber-600');
|
||
}
|
||
} catch (e2) {
|
||
err.textContent = 'Error: ' + e2.message;
|
||
err.classList.remove('hidden');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = t('Send');
|
||
}
|
||
}
|
||
|
||
async function pysimStatusPoll() {
|
||
const btn = document.getElementById('pli-status-btn');
|
||
if (!btn) return;
|
||
btn.disabled = true;
|
||
btn.textContent = t('STATUS...');
|
||
try {
|
||
const resp = await pysimFetch('/api/status-poll', {});
|
||
if (resp.fetch) {
|
||
btn.textContent = 'SW: ' + resp.sw + ' + ' + resp.fetch_sw;
|
||
} else {
|
||
btn.textContent = 'SW: ' + resp.sw;
|
||
}
|
||
pysimEventsRender();
|
||
pysimProactiveLogRender();
|
||
} catch (e) {
|
||
btn.textContent = t('Error');
|
||
} finally {
|
||
setTimeout(() => { btn.textContent = t('Send STATUS'); btn.disabled = false; }, 2000);
|
||
}
|
||
}
|
||
|
||
let _pysimPollTimer = null;
|
||
|
||
async function pysimPollToggle() {
|
||
const btn = document.getElementById('pli-pause-btn');
|
||
if (!btn) return;
|
||
try {
|
||
const status = await pysimFetch('/api/poll-status');
|
||
const newEnabled = !status.enabled;
|
||
const resp = await pysimFetch('/api/poll-toggle', { enabled: newEnabled });
|
||
pysimUpdatePollUI(resp.enabled, resp.interval);
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
function pysimUpdatePollUI(enabled, interval) {
|
||
const btn = document.getElementById('pli-pause-btn');
|
||
const stat = document.getElementById('pli-poll-stat');
|
||
if (!btn) return;
|
||
if (enabled) {
|
||
btn.textContent = 'ON';
|
||
btn.classList.remove('bg-gray-200', 'dark:bg-slate-700', 'text-gray-700', 'dark:text-slate-300');
|
||
btn.classList.add('bg-emerald-600', 'text-white');
|
||
} else {
|
||
btn.textContent = 'OFF';
|
||
btn.classList.remove('bg-emerald-600', 'text-white');
|
||
btn.classList.add('bg-gray-200', 'dark:bg-slate-700', 'text-gray-700', 'dark:text-slate-300');
|
||
}
|
||
if (stat) stat.textContent = enabled ? '(' + interval + 's)' : '';
|
||
}
|
||
|
||
async function pysimPollStatusInit() {
|
||
try {
|
||
const status = await pysimFetch('/api/poll-status');
|
||
pysimUpdatePollUI(status.enabled, status.interval);
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
function pysimStartBackendPoll() {
|
||
if (_pysimPollTimer) clearInterval(_pysimPollTimer);
|
||
let _hadData = false;
|
||
_pysimPollTimer = setInterval(async () => {
|
||
try {
|
||
const [log, stk, ps] = await Promise.all([
|
||
pysimFetch('/api/proactive-log'),
|
||
pysimFetch('/api/stk-status'),
|
||
pysimFetch('/api/poll-status'),
|
||
]);
|
||
pysimUpdatePollUI(ps.enabled, ps.interval);
|
||
if (Array.isArray(log) && log.length > 0) _hadData = true;
|
||
if (_hadData && !ps.enabled && Array.isArray(log) && log.length === 0) {
|
||
_hadData = false;
|
||
const statusEl = document.getElementById('pysim-status');
|
||
if (statusEl && statusEl.textContent.trim() !== '') {
|
||
statusEl.innerHTML = '<span class="text-red-500">Card disconnected</span>';
|
||
}
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
}, 5000);
|
||
}
|
||
|
||
// ===== proactive command log =====
|
||
const CMD_NAMES = {
|
||
'03': 'POLL INTERVAL', '05': 'SET UP EVENT LIST',
|
||
'13': 'SEND SHORT MESSAGE', '20': 'PLAY TONE',
|
||
'21': 'DISPLAY TEXT', '22': 'GET INKEY', '23': 'GET INPUT',
|
||
'24': 'SELECT ITEM', '25': 'SET UP MENU',
|
||
'26': 'PROVIDE LOCAL INFORMATION',
|
||
'15': 'LAUNCH BROWSER', '70': 'ACTIVATE',
|
||
};
|
||
|
||
const CMD_QUALIFIER_SHORT = {
|
||
'01': {0x00:'NAA Init+FC', 0x01:'File Chg', 0x02:'NAA Init+FC', 0x03:'NAA Init',
|
||
0x04:'UICC Reset', 0x05:'App Reset', 0x06:'Sess Reset', 0x07:'SoR', 0x08:'SoR I-WLAN'},
|
||
'10': {0x00:'IfNotBusy', 0x01:'IfNotBusy+Redial', 0x02:'HoldOthers', 0x03:'HoldOthers+Redial',
|
||
0x04:'DiscOthers', 0x05:'DiscOthers+Redial'},
|
||
'15': {0x00:'New', 0x01:'Existing', 0x02:'Close+New'},
|
||
'21': {0x00:'Normal', 0x01:'HighPri', 0x80:'WaitUser'},
|
||
'22': {0x00:'Digits', 0x01:'Alpha', 0x04:'YesNo', 0x08:'ImmDigit'},
|
||
'23': {0x00:'Digits+Echo', 0x01:'Alpha+Echo', 0x04:'NoEcho'},
|
||
'24': {0x00:'Normal', 0x02:'Help', 0x04:'DefaultItem', 0x80:'Nav'},
|
||
'25': {0x00:'Normal', 0x04:'Help', 0x80:'Nav'},
|
||
'26': {0x00:'Loc', 0x01:'IMEI', 0x02:'NMR', 0x03:'Time', 0x04:'Lang', 0x05:'TA',
|
||
0x06:'AccTech', 0x08:'IMEISV', 0x09:'Search', 0x0A:'Batt', 0x0C:'WSID',
|
||
0x0D:'BCInfo', 0x0E:'MultiAT', 0x0F:'MultiLoc', 0x10:'MultiNMR',
|
||
0x11:'CSG', 0x12:'HNB-IP', 0x13:'HNB-Macro', 0x14:'WLAN', 0x15:'Slices',
|
||
0x16:'CAG', 0x17:'RejSlice'},
|
||
};
|
||
|
||
let _pysimLogExpanded = new Set();
|
||
|
||
function pysimLogToggle(id) {
|
||
const panel = document.querySelector('[data-log-panel="' + id + '"]');
|
||
const tog = document.querySelector('[data-log-tog="' + id + '"]');
|
||
if (_pysimLogExpanded.has(id)) {
|
||
_pysimLogExpanded.delete(id);
|
||
if (panel) panel.classList.add('hidden');
|
||
if (tog) tog.textContent = '▶';
|
||
} else {
|
||
_pysimLogExpanded.add(id);
|
||
if (panel) panel.classList.remove('hidden');
|
||
if (tog) tog.textContent = '▼';
|
||
}
|
||
}
|
||
|
||
function pysimLogFields(decoded) {
|
||
if (!decoded || !decoded.length) return '';
|
||
let html = '';
|
||
decoded.forEach(f => {
|
||
html += '<div class="flex gap-1"><span class="text-gray-500 dark:text-slate-400 shrink-0">'
|
||
+ esc(f.label) + ':</span><span class="break-all">' + esc(f.value) + '</span></div>';
|
||
});
|
||
return html;
|
||
}
|
||
|
||
async function pysimProactiveLogRender() {
|
||
const el = document.getElementById('pysim-proactive-log');
|
||
el.innerHTML = '<span class="text-gray-400">' + t('Loading...') + '</span>';
|
||
try {
|
||
const log = await pysimFetch('/api/proactive-log');
|
||
if (!log || !log.length) {
|
||
el.innerHTML = '<span class="text-gray-400">' + t('No commands logged yet.') + '</span>';
|
||
return;
|
||
}
|
||
let html = '';
|
||
log.forEach((e, i) => {
|
||
const name = CMD_NAMES[e.type_hex] || ('Cmd 0x' + e.type_hex.toUpperCase());
|
||
let qual = '';
|
||
if (e.qualifier) {
|
||
const qs = CMD_QUALIFIER_SHORT[e.type_hex];
|
||
if (qs) {
|
||
const qv = qs[parseInt(e.qualifier,16)];
|
||
if (qv) qual = ' [' + qv + ']';
|
||
}
|
||
}
|
||
const expanded = _pysimLogExpanded.has(e.id);
|
||
html += '<div data-log-id="' + e.id + '" class="border-b border-gray-100 dark:border-slate-700/50">'
|
||
+ '<div class="flex items-center gap-2 py-0.5 cursor-pointer select-none" onclick="pysimLogToggle(' + e.id + ')">'
|
||
+ '<span class="w-12 text-right text-gray-400">' + e.elapsed + 's</span>'
|
||
+ '<span class="font-mono text-gray-400">0x' + e.type_hex.toUpperCase() + '</span>'
|
||
+ '<span class="truncate">' + esc(name) + '</span>'
|
||
+ (qual ? '<span class="text-xs text-gray-500 dark:text-slate-400">' + esc(qual) + '</span>' : '')
|
||
+ '<span class="ml-auto text-gray-400" data-log-tog="' + e.id + '">' + (expanded ? '▼' : '▶') + '</span>'
|
||
+ '</div>'
|
||
+ '<div data-log-panel="' + e.id + '" class="' + (expanded ? '' : 'hidden') + ' ml-8 pl-2 border-l border-gray-300 dark:border-slate-600 mb-1 text-xs space-y-0.5">'
|
||
+ '<div class="text-gray-500 dark:text-slate-400">' + t('Command') + ':</div>'
|
||
+ pysimLogFields(e.cmd_decoded);
|
||
if (e.raw) html += '<input type="text" readonly value="' + esc(e.raw) + '" class="w-full max-w-md font-mono text-xs rounded border border-gray-300 dark:border-slate-600 px-2 py-1 bg-gray-100 dark:bg-slate-800 text-gray-600 dark:text-slate-400">';
|
||
html += '<div class="text-gray-500 dark:text-slate-400 mt-1">' + t('Response') + ':</div>';
|
||
if (e.tr_hex !== undefined) {
|
||
if (e.tr_result !== undefined) {
|
||
html += '<div class="flex gap-1"><span class="text-gray-500 dark:text-slate-400 shrink-0">' + t('Result') + ':</span>'
|
||
+ '<span class="break-all">' + (e.tr_result_name ? esc(e.tr_result_name) + ' (' + esc(e.tr_result) + ')' : esc(e.tr_result)) + '</span></div>';
|
||
}
|
||
if (e.tr_hex) {
|
||
html += pysimLogFields(e.tr_decoded);
|
||
html += '<input type="text" readonly value="' + esc(e.tr_hex) + '" class="w-full max-w-md font-mono text-xs rounded border border-gray-300 dark:border-slate-600 px-2 py-1 bg-gray-100 dark:bg-slate-800 text-gray-600 dark:text-slate-400">';
|
||
}
|
||
} else {
|
||
html += '<div class="text-gray-400">' + t('No response yet') + '</div>';
|
||
}
|
||
html += '</div></div>';
|
||
});
|
||
el.innerHTML = html;
|
||
} catch (err) {
|
||
el.innerHTML = '<span class="text-red-500">Error: ' + esc(err.message) + '</span>';
|
||
}
|
||
}
|
||
|
||
// ===== PLI data dictionary =====
|
||
function decPlmn(hex3) {
|
||
const h = hex3.replace(/\s/g, '').slice(0, 6);
|
||
if (h.length < 6) return ['250', '01'];
|
||
const b1 = parseInt(h[0]+h[1], 16), b2 = parseInt(h[2]+h[3], 16), b3 = parseInt(h[4]+h[5], 16);
|
||
const mcc = '' + (b1&0xF) + ((b1>>4)&0xF) + (b2&0xF);
|
||
let mnc = '' + (b3&0xF);
|
||
if ((b2>>4) !== 0xF) mnc += ((b2>>4)&0xF);
|
||
return [mcc, mnc];
|
||
}
|
||
|
||
function decImei(hex8) {
|
||
const h = hex8.replace(/\s/g, '').slice(0, 16);
|
||
if (h.length < 16) return '';
|
||
let s = '';
|
||
for (let i = 0; i < 15; i += 2) {
|
||
const b = parseInt(h[i]+h[i+1], 16);
|
||
s += '' + (b&0xF) + ((b>>4)&0xF);
|
||
}
|
||
return s.slice(0, 15);
|
||
}
|
||
|
||
function encImeiPli(digits) {
|
||
const d = digits.replace(/\D/g, '').slice(0, 15);
|
||
if (d.length < 15) return '';
|
||
let h = '';
|
||
for (let i = 0; i < 15; i += 2)
|
||
h += (parseInt(d[i+1])<<4 | parseInt(d[i])).toString(16).padStart(2, '0');
|
||
return h;
|
||
}
|
||
|
||
function encImeisvPli(digits) {
|
||
const d = digits.replace(/\D/g, '').slice(0, 16);
|
||
if (d.length < 16) return '';
|
||
let h = '';
|
||
for (let i = 0; i < 16; i += 2)
|
||
h += (parseInt(d[i+1])<<4 | parseInt(d[i])).toString(16).padStart(2, '0');
|
||
return h;
|
||
}
|
||
|
||
const ACCESSTECH = [
|
||
{v:0,l:'GSM'},{v:1,l:'GSM Compact'},{v:2,l:'TIA/EIA-533'},{v:3,l:'UTRAN'},
|
||
{v:4,l:'TETRA'},{v:5,l:'TIA/EIA-95-B'},{v:6,l:'CDMA2000 1x'},{v:7,l:'CDMA2000 HRPD'},
|
||
{v:8,l:'E-UTRAN'},{v:9,l:'eHRPD'},{v:10,l:'NG-RAN'},{v:11,l:'Satellite NG-RAN'},{v:12,l:'Satellite E-UTRAN'},
|
||
];
|
||
|
||
const PLI_CODECS = {
|
||
'00': { tlv: '9305',
|
||
decode(hex) {
|
||
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 10) return null;
|
||
const pl = decPlmn(v); return { mcc: pl[0], mnc: pl[1], lac: v.slice(6,10).toUpperCase() };
|
||
},
|
||
encode(v) { return '9305' + encPlmn(v.mcc||'250',v.mnc||'01') + ((v.lac||'0000').toUpperCase()); },
|
||
fields: [
|
||
{id:'mcc',label:'MCC',type:'text',pattern:'\\d{3}',placeholder:'e.g. 250'},
|
||
{id:'mnc',label:'MNC',type:'text',pattern:'\\d{2,3}',placeholder:'e.g. 01'},
|
||
{id:'lac',label:'LAC/TAC',type:'text',pattern:'[0-9A-Fa-f]{4}',placeholder:'hex'},
|
||
],
|
||
},
|
||
'01': { tlv: '9408',
|
||
decode(hex) {
|
||
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 16) return null;
|
||
return { imei: decImei(v) };
|
||
},
|
||
encode(v) { const imei = encImeiPli(v.imei||''); return imei ? '9408'+imei : ''; },
|
||
fields: [
|
||
{id:'imei',label:'IMEI',type:'text',pattern:'\\d{15}',placeholder:'15 digits'},
|
||
],
|
||
},
|
||
'03': { tlv: '2607',
|
||
decode(hex) {
|
||
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 14) return null;
|
||
const yr = parseInt(v[0]+v[1])<70 ? 2000+parseInt(v[0]+v[1]) : 1900+parseInt(v[0]+v[1]);
|
||
const mo = parseInt(v[2]+v[3]), dy = parseInt(v[4]+v[5]);
|
||
const hh = parseInt(v[6]+v[7]), mm = parseInt(v[8]+v[9]), ss = parseInt(v[10]+v[11]);
|
||
const tz = parseInt(v[12]+v[13], 16);
|
||
const tzSign = (tz & 0x80) ? '-' : '+';
|
||
const tzH = Math.floor(((tz & 0x3F) || 0) / 4);
|
||
const tzM = (((tz & 0x3F) || 0) % 4) * 15;
|
||
return {
|
||
dt: yr+'-'+(''+mo).padStart(2,'0')+'-'+(''+dy).padStart(2,'0'),
|
||
tm: (''+hh).padStart(2,'0')+':'+(''+mm).padStart(2,'0')+':'+(''+ss).padStart(2,'0'),
|
||
tz: tzSign+(''+tzH).padStart(2,'0')+':'+(''+tzM).padStart(2,'0'),
|
||
};
|
||
},
|
||
encode(v) {
|
||
const dt = (v.dt||'2000-01-01').split('-'), tm = (v.tm||'00:00:00').split(':');
|
||
let tzQ = 0;
|
||
const tzM = v.tz ? v.tz.match(/^([-+])(\d{2}):?(\d{2})$/) : null;
|
||
if (tzM) { const h = parseInt(tzM[2])||0, m = parseInt(tzM[3])||0; tzQ = h*4 + Math.floor(m/15); if (tzM[1]==='-') tzQ |= 0x80; }
|
||
return '2607'
|
||
+ (parseInt(dt[0])%100).toString(16).padStart(2,'0')
|
||
+ (parseInt(dt[1])||1).toString(16).padStart(2,'0')
|
||
+ (parseInt(dt[2])||1).toString(16).padStart(2,'0')
|
||
+ (parseInt(tm[0])||0).toString(16).padStart(2,'0')
|
||
+ (parseInt(tm[1])||0).toString(16).padStart(2,'0')
|
||
+ (parseInt(tm[2])||0).toString(16).padStart(2,'0')
|
||
+ tzQ.toString(16).padStart(2,'0');
|
||
},
|
||
fields: [
|
||
{id:'dt',label:'Date',type:'date'},{id:'tm',label:'Time',type:'time'},
|
||
{id:'tz',label:'TZ offset',type:'text',pattern:'[-+]\\d{2}:?\\d{2}',placeholder:'+00:00'},
|
||
],
|
||
},
|
||
'04': { tlv: 'AD02',
|
||
decode(hex) {
|
||
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 4) return null;
|
||
return { lang: String.fromCharCode(parseInt(v[0]+v[1],16), parseInt(v[2]+v[3],16)) };
|
||
},
|
||
encode(v) {
|
||
const lang = (v.lang||'en').slice(0,2);
|
||
return 'AD02' + lang.charCodeAt(0).toString(16).padStart(2,'0') + lang.charCodeAt(1).toString(16).padStart(2,'0');
|
||
},
|
||
fields: [
|
||
{id:'lang',label:'Language',type:'text',pattern:'[a-zA-Z]{2,3}',placeholder:'en'},
|
||
],
|
||
},
|
||
'05': { tlv: 'C602',
|
||
decode(hex) {
|
||
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 4) return null;
|
||
return { status: parseInt(v[0]+v[1],16).toString(), ta: parseInt(v[2]+v[3],16).toString() };
|
||
},
|
||
encode(v) { return 'C602' + (parseInt(v.status)||0).toString(16).padStart(2,'0') + (parseInt(v.ta)||0).toString(16).padStart(2,'0'); },
|
||
fields: [
|
||
{id:'status',label:'ME Status',type:'number',min:'0',max:'255',placeholder:'0'},
|
||
{id:'ta',label:'Timing Advance',type:'number',min:'0',max:'255',placeholder:'0'},
|
||
],
|
||
},
|
||
'06': { tlv: 'BF01',
|
||
decode(hex) {
|
||
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 2) return null;
|
||
return { tech: parseInt(v[0]+v[1],16).toString() };
|
||
},
|
||
encode(v) { return 'BF01' + (parseInt(v.tech)||0).toString(16).padStart(2,'0'); },
|
||
fields: [{id:'tech',label:'Access Technology',type:'select',opts:ACCESSTECH}],
|
||
},
|
||
'08': { tlv: '9408',
|
||
decode(hex) {
|
||
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 16) return null;
|
||
return { imeisv: decImei(v) + v.slice(14,16).toUpperCase() };
|
||
},
|
||
encode(v) {
|
||
const raw = (v.imeisv||'').replace(/\D/g, '').slice(0, 16);
|
||
if (raw.length < 16) return '';
|
||
let h = '';
|
||
for (let i = 0; i < 16; i += 2)
|
||
h += (parseInt(raw[i+1])<<4 | parseInt(raw[i])).toString(16).padStart(2,'0');
|
||
return '9408' + h;
|
||
},
|
||
fields: [{id:'imeisv',label:'IMEISV',type:'text',pattern:'\\d{16}',placeholder:'16 digits'}],
|
||
},
|
||
'09': { tlv: 'AD01',
|
||
decode(hex) {
|
||
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 2) return null;
|
||
return { mode: parseInt(v[0]+v[1],16).toString() };
|
||
},
|
||
encode(v) {
|
||
return 'AD01' + (parseInt(v.mode)||0).toString(16).padStart(2,'0');
|
||
},
|
||
fields: [{id:'mode',label:'Search Mode',type:'select',
|
||
opts:[{v:0,l:'Automatic'},{v:1,l:'Manual'}]}],
|
||
},
|
||
'0A': { tlv: '6301',
|
||
decode(hex) {
|
||
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 2) return null;
|
||
return { charge: parseInt(v[0]+v[1],16).toString() };
|
||
},
|
||
encode(v) { return '6301' + (parseInt(v.charge)||50).toString(16).padStart(2,'0'); },
|
||
fields: [{id:'charge',label:'Charge state (%)',type:'number',min:'0',max:'100',placeholder:'50'}],
|
||
},
|
||
'0E': { tlv: 'BF',
|
||
decode(hex) {
|
||
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 2) return null;
|
||
const techs = []; for (let i = 0; i < v.length; i += 2) techs.push(parseInt(v[i]+v[i+1],16));
|
||
return { techs: techs.join(',') };
|
||
},
|
||
encode(v) {
|
||
const techs = (v.techs||'').split(',').filter(t => t !== '').map(t => parseInt(t)&0xFF);
|
||
if (!techs.length) return '';
|
||
const len = techs.length.toString(16).padStart(2,'0');
|
||
return 'BF' + len + techs.map(t => t.toString(16).padStart(2,'0')).join('');
|
||
},
|
||
fields: [
|
||
{id:'techs',label:'Access Technologies (comma-separated)',type:'text',
|
||
placeholder:'e.g. 3,8,10 (= UTRAN + E-UTRAN + NG-RAN)'},
|
||
],
|
||
},
|
||
};
|
||
async function pysimPliRender() {
|
||
const el = document.getElementById('pysim-pli-dict');
|
||
el.innerHTML = '<span class="text-gray-400">' + t('Loading...') + '</span>';
|
||
try {
|
||
const data = await pysimFetch('/api/pli-dict');
|
||
let html = '<div class="space-y-1">';
|
||
PLI_QUALIFIERS.forEach(q => {
|
||
const val = data[q.code] || '';
|
||
const hasCodec = !!PLI_CODECS[q.code];
|
||
html += '<div data-pli-row="' + q.code + '" class="py-0.5 border-b border-gray-200 dark:border-slate-700">'
|
||
+ '<div class="flex items-baseline gap-1.5">'
|
||
+ '<span class="w-6 shrink-0 text-right text-gray-400 text-xs">' + q.code + '</span>'
|
||
+ '<span class="flex-1 min-w-0 text-[13px] leading-snug">' + esc(q.name) + '</span>'
|
||
+ '</div>'
|
||
+ '<div class="flex items-center gap-1 mt-0.5 ml-8">'
|
||
+ '<input type="text" id="pli-v-' + q.code + '" value="' + esc(val) + '" placeholder="hex"'
|
||
+ ' class="flex-1 min-w-0 border border-gray-300 dark:border-slate-600 text-xs rounded px-1.5 py-1 dark:bg-slate-800 font-mono">';
|
||
if (hasCodec) {
|
||
html += '<span onclick="pysimPliDecodeRow(\'' + q.code + '\')" id="pli-tog-' + q.code + '"'
|
||
+ ' class="cursor-pointer select-none text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">▶</span>';
|
||
}
|
||
html += '</div><div class="pli-decode-panel hidden mt-1 ml-8 pl-2 border-l border-gray-300 dark:border-slate-600" data-pli-panel="' + q.code + '"></div></div>';
|
||
});
|
||
html += '</div>';
|
||
el.innerHTML = html;
|
||
} catch (e) {
|
||
el.innerHTML = '<span class="text-red-500">Error: ' + esc(e.message) + '</span>';
|
||
}
|
||
}
|
||
|
||
function pysimPliDecodeRow(code) {
|
||
const panel = document.querySelector('[data-pli-panel="' + code + '"]');
|
||
const codec = PLI_CODECS[code];
|
||
if (!codec) return;
|
||
|
||
if (!panel.classList.contains('hidden')) {
|
||
panel.innerHTML = '';
|
||
panel.classList.add('hidden');
|
||
const tog = document.getElementById('pli-tog-' + code);
|
||
if (tog) tog.textContent = '▶';
|
||
return;
|
||
}
|
||
|
||
const input = document.getElementById('pli-v-' + code);
|
||
const hex = input.value.trim().replace(/\s/g, '');
|
||
let values = {};
|
||
if (hex) {
|
||
values = codec.decode(hex);
|
||
if (!values) {
|
||
panel.innerHTML = '<span class="text-xs text-red-500">Invalid hex</span>';
|
||
panel.classList.remove('hidden');
|
||
const tog = document.getElementById('pli-tog-' + code);
|
||
if (tog) tog.textContent = '▼';
|
||
return;
|
||
}
|
||
}
|
||
|
||
let html = '<div class="space-y-1 mb-1">';
|
||
codec.fields.forEach(f => {
|
||
html += '<div class="flex items-center gap-1">'
|
||
+ '<span class="text-xs w-12 text-gray-500 dark:text-slate-400 truncate" title="' + esc(f.label) + '">' + esc(f.label) + '</span>';
|
||
if (f.type === 'select') {
|
||
html += '<select id="pli-d-' + code + '-' + f.id + '" class="text-xs border border-gray-300 dark:border-slate-600 rounded px-1.5 py-1 dark:bg-slate-800 w-40">';
|
||
f.opts.forEach(o => html += '<option value="' + o.v + '"' + (String(values[f.id]) === String(o.v) ? ' selected' : '') + '>' + esc(o.l) + '</option>');
|
||
html += '</select>';
|
||
} else {
|
||
html += '<input id="pli-d-' + code + '-' + f.id + '" type="' + f.type + '"'
|
||
+ ' value="' + esc(String(values[f.id] || '')) + '"'
|
||
+ (f.pattern ? ' pattern="' + f.pattern + '"' : '')
|
||
+ (f.placeholder ? ' placeholder="' + f.placeholder + '"' : '')
|
||
+ (f.min !== undefined ? ' min="' + f.min + '" max="' + f.max + '"' : '')
|
||
+ ' class="text-xs border border-gray-300 dark:border-slate-600 rounded px-1.5 py-1 dark:bg-slate-800 w-40 font-mono">';
|
||
}
|
||
html += '</div>';
|
||
});
|
||
html += '</div>';
|
||
html += '<div class="flex gap-1">'
|
||
+ '<button onclick="pysimPliApplyRow(\'' + code + '\')" class="text-xs px-2 py-1 rounded bg-emerald-600 text-white hover:bg-emerald-700">Apply</button>'
|
||
+ '<button onclick="var p=document.querySelector(\'[data-pli-panel=\\\'' + code + '\\\']\');p.classList.add(\'hidden\');p.innerHTML=\'\';var t=document.getElementById(\'pli-tog-' + code + '\');if(t)t.textContent=\'▶\'" class="text-xs px-2 py-1 rounded border border-gray-300 dark:border-slate-600">Cancel</button>'
|
||
+ '</div>';
|
||
panel.innerHTML = html;
|
||
panel.classList.remove('hidden');
|
||
const tog = document.getElementById('pli-tog-' + code);
|
||
if (tog) tog.textContent = '▼';
|
||
}
|
||
|
||
function pysimPliApplyRow(code) {
|
||
const codec = PLI_CODECS[code];
|
||
if (!codec) return;
|
||
const values = {};
|
||
codec.fields.forEach(f => {
|
||
const el = document.getElementById('pli-d-' + code + '-' + f.id);
|
||
if (el) values[f.id] = el.value;
|
||
});
|
||
const hex = codec.encode(values);
|
||
if (!hex) return;
|
||
const input = document.getElementById('pli-v-' + code);
|
||
if (input) input.value = hex;
|
||
const panel = document.querySelector('[data-pli-panel="' + code + '"]');
|
||
if (panel) { panel.innerHTML = ''; panel.classList.add('hidden'); }
|
||
const tog = document.getElementById('pli-tog-' + code);
|
||
if (tog) tog.textContent = '▶';
|
||
}
|
||
|
||
async function pysimPliSave() {
|
||
const btn = document.getElementById('pli-save-btn');
|
||
btn.disabled = true;
|
||
btn.textContent = t('Saving...');
|
||
const data = {};
|
||
PLI_QUALIFIERS.forEach(q => {
|
||
const el = document.getElementById('pli-v-' + q.code);
|
||
if (el) {
|
||
const v = el.value.trim().replace(/\s/g, '');
|
||
if (v && !/^[0-9A-Fa-f]*$/.test(v)) return;
|
||
data[q.code] = v;
|
||
}
|
||
});
|
||
try {
|
||
await pysimFetch('/api/pli-dict', data);
|
||
btn.textContent = t('Saved');
|
||
btn.classList.add('bg-emerald-700');
|
||
setTimeout(() => { btn.textContent = t('Save'); btn.classList.remove('bg-emerald-700'); btn.disabled = false; }, 1500);
|
||
} catch (e) {
|
||
btn.textContent = 'Error';
|
||
btn.classList.add('bg-red-600');
|
||
setTimeout(() => { btn.textContent = t('Save'); btn.classList.remove('bg-red-600'); btn.disabled = false; }, 2000);
|
||
}
|
||
}
|
||
|
||
// ===== custom files =====
|
||
let pysimCustomFiles = [];
|
||
|
||
function pysimCustomLoad() {
|
||
try {
|
||
const d = localStorage.getItem('pysim_custom_files');
|
||
pysimCustomFiles = d ? JSON.parse(d) : [];
|
||
} catch (e) { pysimCustomFiles = []; }
|
||
}
|
||
|
||
function pysimCustomSave() {
|
||
localStorage.setItem('pysim_custom_files', JSON.stringify(pysimCustomFiles));
|
||
}
|
||
|
||
function pysimCustomAdd() {
|
||
const pathEl = document.getElementById('pysim-cf-path');
|
||
const nameEl = document.getElementById('pysim-cf-name');
|
||
const path = pathEl.value.trim().toUpperCase();
|
||
const name = nameEl.value.trim();
|
||
if (!path || !name) return;
|
||
if (!name.startsWith('EF.') && !name.startsWith('DF.')) {
|
||
alert('Alias must start with "EF." or "DF."');
|
||
return;
|
||
}
|
||
const parts = path.split('/').filter(Boolean);
|
||
if (parts.length < 2) { alert('Path must have at least 2 parts, e.g. 3F00/6F46'); return; }
|
||
const fid = parts[parts.length - 1];
|
||
const parentFid = parts[parts.length - 2];
|
||
if (pysimCustomFiles.some(c => c.path.toUpperCase() === path)) { alert('Path already exists'); return; }
|
||
pysimCustomFiles.push({ path, name, fid, parentFid });
|
||
pysimCustomSave();
|
||
pysimCustomRender();
|
||
pathEl.value = '';
|
||
nameEl.value = '';
|
||
}
|
||
|
||
function pysimCustomRemove(i) {
|
||
pysimCustomFiles.splice(i, 1);
|
||
pysimCustomSave();
|
||
pysimCustomRender();
|
||
}
|
||
|
||
function pysimCustomRender() {
|
||
const el = document.getElementById('pysim-cf-list');
|
||
if (!pysimCustomFiles.length) { el.innerHTML = '<span class="text-gray-400">No custom files defined.</span>'; return; }
|
||
let html = '';
|
||
for (let i = 0; i < pysimCustomFiles.length; i++) {
|
||
const c = pysimCustomFiles[i];
|
||
html += '<div class="flex items-center gap-2 py-1 border-b border-gray-200 dark:border-slate-700">';
|
||
html += '<span class="text-gray-500">' + esc(c.path) + '</span> <span class="text-blue-600 dark:text-blue-400">→</span> <b>' + esc(c.name) + '</b>';
|
||
html += '<span onclick="pysimCustomRemove(' + i + ')" class="ml-auto cursor-pointer text-red-400 hover:text-red-600">✕</span>';
|
||
html += '</div>';
|
||
}
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
function pysimCustomExport() {
|
||
const el = document.getElementById('pysim-cf-io');
|
||
el.value = JSON.stringify(pysimCustomFiles, null, 2);
|
||
el.classList.remove('hidden');
|
||
el.select();
|
||
}
|
||
|
||
function pysimCustomExportFile() {
|
||
downloadJson('otaman-custom-files.json', pysimCustomFiles);
|
||
}
|
||
|
||
async function pysimCustomImport(text) {
|
||
try {
|
||
if (text == null) text = await navigator.clipboard.readText();
|
||
const imported = JSON.parse(text);
|
||
if (!Array.isArray(imported)) throw new Error('Expected an array');
|
||
let added = 0;
|
||
for (const entry of imported) {
|
||
if (!entry.path || !entry.name) continue;
|
||
const path = entry.path.toUpperCase();
|
||
if (!pysimCustomFiles.some(c => c.path.toUpperCase() === path)) {
|
||
const parts = path.split('/').filter(Boolean);
|
||
pysimCustomFiles.push({
|
||
path: path,
|
||
name: entry.name.toUpperCase(),
|
||
fid: (entry.fid || parts[parts.length - 1]).toUpperCase(),
|
||
parentFid: (entry.parentFid || parts[parts.length - 2]).toUpperCase(),
|
||
});
|
||
added++;
|
||
}
|
||
}
|
||
pysimCustomSave();
|
||
pysimCustomRender();
|
||
const el = document.getElementById('pysim-cf-io');
|
||
el.value = 'Imported ' + added + ' files.';
|
||
setTimeout(() => { el.classList.add('hidden'); }, 2000);
|
||
} catch (e) {
|
||
alert('Import error: ' + e.message);
|
||
}
|
||
}
|
||
|
||
function pysimCustomImportFile(input) {
|
||
const file = input.files && input.files[0];
|
||
input.value = '';
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = () => pysimCustomImport(reader.result);
|
||
reader.readAsText(file);
|
||
}
|
||
|
||
function pysimCustomImportField() {
|
||
const el = document.getElementById('pysim-cf-io');
|
||
const val = el.value.trim();
|
||
if (!el.classList.contains('hidden') && val.startsWith('[')) { pysimCustomImport(val); return; }
|
||
el.value = '';
|
||
el.placeholder = 'Paste JSON array here, then press Paste & import again';
|
||
el.classList.remove('hidden');
|
||
el.focus();
|
||
}
|
||
|
||
// Init custom files
|
||
pysimCustomLoad();
|
||
|
||
// Init sub-tab pills
|
||
document.querySelectorAll('.pysim-subtab').forEach(btn => {
|
||
btn.addEventListener('click', () => pysimSwitchSubtab(btn.dataset.pysimSub));
|
||
});
|
||
|
||
// ===== i18n =====
|
||
const LANG_RU = {
|
||
'Generate APDU': 'Сгенерировать APDU',
|
||
'Pack into Secured packet': 'Упаковать в Secured packet',
|
||
'Convert': 'Конвертировать',
|
||
'Decode': 'Декодировать',
|
||
'Generate Expanded Script APDU': 'Сгенерировать Expanded Script APDU',
|
||
'Generate Secure Packet': 'Сгенерировать Secure Packet',
|
||
'Conversion': 'Конвертация',
|
||
'Command': 'Команда',
|
||
'Response': 'Ответ',
|
||
'Result': 'Результат',
|
||
'No response yet': 'Ответа пока нет',
|
||
'Start with SELECT': 'Начать с SELECT',
|
||
'SELECT method': 'Метод SELECT',
|
||
'Path (hex)': 'Путь (hex)',
|
||
'FID chain (comma-separated)': 'FID цепочка (через запятую)',
|
||
'Record #': 'Запись №',
|
||
'Selection mode (P2)': 'Режим выборки (P2)',
|
||
'Offset (hex)': 'Смещение (hex)',
|
||
'Data (hex)': 'Данные (hex)',
|
||
'Record size (bytes)': 'Размер записи (байт)',
|
||
'Allow P1/P2 editing': 'Разрешить редактирование P1/P2',
|
||
'Privileges': 'Привилегии',
|
||
'PIN value': 'Значение PIN',
|
||
'Old PIN': 'Старый PIN',
|
||
'New PIN': 'Новый PIN',
|
||
'ELF AID (hex)': 'ELF AID (hex)',
|
||
'Module AID (hex)': 'AID модуля (hex)',
|
||
'Security level (P1)': 'Уровень безопасности (P1)',
|
||
'Target': 'Цель',
|
||
'Current EF (case 1)': 'Текущий EF (case 1)',
|
||
'By FID': 'По FID',
|
||
'Path from MF': 'Путь от MF',
|
||
'Path from current DF': 'Путь от текущего DF',
|
||
'Unblock PIN': 'Разблокировка PIN',
|
||
'Byte 1': 'Байт 1',
|
||
'Byte 2': 'Байт 2',
|
||
'SIM/UICC Toolkit parameters': 'Параметры SIM/UICC Toolkit',
|
||
'Toolkit mode': 'Режим Toolkit',
|
||
'Max services': 'Максимум сервисов',
|
||
'Priority': 'Приоритет',
|
||
'Timers (max)': 'Таймеры (макс)',
|
||
'Menu text length': 'Длина текста меню',
|
||
'Menu items': 'Пункты меню',
|
||
'First menu position': 'Первая позиция в меню',
|
||
'First menu ID (hex)': 'ID первого пункта меню (hex)',
|
||
'Last menu position': 'Позиция последнего пункта меню',
|
||
'Last menu ID (hex)': 'ID последнего пункта меню (hex)',
|
||
'Channels (max)': 'Каналы (макс)',
|
||
'Access domain (hex)': 'Домен доступа (hex)',
|
||
'Load data (hex)': 'Данные загрузки (hex)',
|
||
'Block number': 'Номер блока',
|
||
'Encryption': 'Шифрование',
|
||
'DELETE mode': 'Режим DELETE',
|
||
'GET STATUS mode': 'Режим GET STATUS',
|
||
'GET STATUS format': 'Формат GET STATUS',
|
||
'SET STATUS type': 'Тип SET STATUS',
|
||
'State': 'Состояние',
|
||
'GET DATA tag': 'Tag GET DATA',
|
||
'Response data (hex)': 'Данные ответа (hex)',
|
||
'— Select command —': '— Выберите команду —',
|
||
'— select preset —': '— выберите предустановленный —',
|
||
'SIM OTA with a Human Face': 'SIM OTA с человеческим лицом',
|
||
'Error: specify SELECT parameters': 'Ошибка: укажите параметры SELECT',
|
||
'Error: unknown command': 'Ошибка: неизвестная команда',
|
||
'Error: specify data': 'Ошибка: укажите данные',
|
||
'Error: specify SD AID': 'Ошибка: укажите SD AID',
|
||
'Error: specify AID': 'Ошибка: укажите AID',
|
||
'Error: specify data (host cryptogram)': 'Ошибка: укажите данные (host cryptogram)',
|
||
'Error: specify data (challenge)': 'Ошибка: укажите данные (challenge)',
|
||
'Error: enter a value': 'Ошибка: введите значение',
|
||
'Error: IMSI must be 15 digits': 'Ошибка: IMSI должен быть 15 цифр',
|
||
'Error: specify MCC and MNC': 'Ошибка: укажите MCC и MNC',
|
||
'Error: MCC must be 3 digits': 'Ошибка: MCC должен быть 3 цифры',
|
||
'Error: MNC must be 2-3 digits': 'Ошибка: MNC должен быть 2-3 цифры',
|
||
'Error: enter an even number of hex characters': 'Ошибка: введите чётное количество hex-символов',
|
||
'Error: specify APDU': 'Ошибка: укажите APDU',
|
||
'KIc key error: ': 'Ошибка KIc key: ',
|
||
'KID key error: ': 'Ошибка KID key: ',
|
||
'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 — под списком карт.',
|
||
'Add': 'Добавить',
|
||
'Export as JSON': 'Экспорт в JSON',
|
||
'Export to file': 'Экспорт в файл',
|
||
'Import from file': 'Импорт из файла',
|
||
'Paste & import': 'Вставить и импортировать',
|
||
'Import JSON from clipboard': 'Импорт JSON из буфера',
|
||
'Name': 'Имя',
|
||
'ICCID': 'ICCID',
|
||
'Cntr': 'Сч',
|
||
'Remove': 'Удалить',
|
||
'No cards defined.': 'Карты не заданы.',
|
||
'Card presets': 'Выбор карты',
|
||
'— Select card —': '— Выберите карту —',
|
||
'Server URL': 'URL сервера',
|
||
'Connect': 'Подключиться',
|
||
'To access local card reader, you need pysim-otaman-server running locally.': 'Для доступа к локальному картридеру необходим запущенный pysim-otaman-server.',
|
||
'To connect, install': 'Для подключения установите',
|
||
'and start it.': 'и запустите его.',
|
||
'Refer to pysim-otaman-server README files for details.': 'Подробности — в README файлах pysim-otaman-server.',
|
||
'Click': 'Нажмите кнопку',
|
||
'when pysim-otaman-server is ready': 'когда pysim-otaman-server будет готов',
|
||
'Equip card': 'Подключить карту',
|
||
'Check status': 'Проверить статус',
|
||
'Reset card': 'Сброс карты',
|
||
'Disconnect from pySim': 'Отключиться от pySim',
|
||
'File manager': 'Файловый менеджер',
|
||
'Custom files': 'Пользовательские файлы',
|
||
'pySim command line': 'Командная строка pySim',
|
||
'Raw APDU': 'Отправка APDU',
|
||
'Read': 'Прочитать',
|
||
'Save': 'Сохранить',
|
||
'Raw': 'Данные как на карте',
|
||
'Decoded': 'Декодированные данные',
|
||
'Edit': 'Редактировать',
|
||
'Execute': 'Выполнить',
|
||
'Send': 'Отправить',
|
||
'Add known file paths and aliases to the file tree.': 'Добавьте пути и псевдонимы файлов в дерево файлов.',
|
||
'File:': 'Файл:',
|
||
'Cancel': 'Отмена',
|
||
'Timeout': 'Таймаут',
|
||
'OK': 'OK',
|
||
'Exit': 'Выход',
|
||
'← Back': '← Назад',
|
||
'Connecting...': 'Подключение...',
|
||
'Checking card...': 'Проверка карты...',
|
||
'Connected': 'Подключено',
|
||
'Connection failed:': 'Ошибка подключения:',
|
||
'pysim-blocked-hint': 'Браузер, вероятно, заблокировал доступ к локальному серверу карт. Разрешите доступ к локальной сети для этого сайта (Chrome/Edge/Vivaldi: Настройки сайта → Доступ к локальной сети → Разрешить). Также проверьте, что pysim-otaman-server запущен по адресу {url}.',
|
||
'pysim-unreachable-hint': 'Не удалось подключиться к серверу карт. Проверьте, что pysim-otaman-server запущен и адрес указан верно ({url}). Если эта страница открыта по HTTPS, также разрешите доступ к локальной сети в браузере (Chrome/Edge/Vivaldi: Настройки сайта → Доступ к локальной сети → Разрешить).',
|
||
'No card detected. Insert card and click Equip card button': 'Карта не обнаружена. Вставьте карту и нажмите Подключить карту',
|
||
'Checking...': 'Проверка...',
|
||
'Resetting...': 'Сброс...',
|
||
'Equipping...': 'Подключение...',
|
||
'Sending OTA...': 'Отправка OTA...',
|
||
'Verifying against pySim reference...': 'Сверка с эталоном pySim...',
|
||
'Verify vs pySim': 'Проверить в pySim',
|
||
'Send to Card': 'Отправить на карту',
|
||
'Proactive UICC': 'Проактивный UICC',
|
||
'Events that the card monitors (SET UP EVENT LIST):': 'События, отслеживаемые картой (SET UP EVENT LIST):',
|
||
'No events configured.': 'Нет настроенных событий.',
|
||
'Fetched proactive commands:': 'Извлечённые проактивные команды:',
|
||
'No commands logged yet.': 'Команды пока не регистрировались.',
|
||
'Send event notification to the card?': 'Отправить уведомление о событии на карту?',
|
||
'This event type is not yet supported.': 'Этот тип события пока не поддерживается.',
|
||
'Event sent.': 'Событие отправлено.',
|
||
'Event data sent.': 'Данные события отправлены.',
|
||
'Sending...': 'Отправка...',
|
||
'Invalid': 'Некорректное значение',
|
||
'Service Status': 'Статус сервиса',
|
||
'Normal service': 'Обычный сервис',
|
||
'Limited service': 'Ограниченный сервис',
|
||
'No service': 'Нет сервиса',
|
||
'Language code': 'Код языка',
|
||
'Registration type': 'Тип регистрации',
|
||
'Access Technology': 'Технология доступа',
|
||
'Rejection cause code': 'Код причины отклонения',
|
||
'Extended EMM cause (optional)': 'Расширенная EMM причина (опционально)',
|
||
'Status': 'Статус',
|
||
'Card reader removed': 'Картридер извлечён',
|
||
'Card reader inserted': 'Картридер вставлен',
|
||
'PROVIDE LOCAL INFORMATION response data': 'Данные для PROVIDE LOCAL INFORMATION',
|
||
'Send STATUS': 'Отправить STATUS',
|
||
'help': 'справка',
|
||
'STATUS and Polling': 'STATUS и опрос',
|
||
'Polling': 'Опрос',
|
||
'STATUS...': 'STATUS...',
|
||
'Saving...': 'Сохранение...',
|
||
'Saved': 'Сохранено',
|
||
'Save': 'Сохранить',
|
||
};
|
||
let currentLang = 'en';
|
||
|
||
function t(text) {
|
||
if (currentLang === 'ru' && LANG_RU[text]) return LANG_RU[text];
|
||
return text;
|
||
}
|
||
|
||
function toggleLang() {
|
||
currentLang = currentLang === 'en' ? 'ru' : 'en';
|
||
localStorage.setItem('lang', currentLang);
|
||
document.getElementById('lang-btn').textContent = currentLang.toUpperCase();
|
||
translatePage();
|
||
translatePage();
|
||
updateHelpLink();
|
||
}
|
||
|
||
let currentHelpAnchor = 'sim-rfm';
|
||
let pysimHelpAnchor = 'file-manager';
|
||
|
||
function updateHelpLink() {
|
||
const link = document.getElementById('help-link');
|
||
if (link) link.href = (currentLang === 'ru' ? 'help-ru.html' : 'help.html') + '#' + currentHelpAnchor;
|
||
}
|
||
|
||
function setHelpAnchor(anchor) {
|
||
currentHelpAnchor = anchor;
|
||
updateHelpLink();
|
||
}
|
||
|
||
function translatePage() {
|
||
document.querySelectorAll('[data-l10n]').forEach(el => {
|
||
const key = el.getAttribute('data-l10n');
|
||
el.textContent = t(key);
|
||
});
|
||
const slogan = document.getElementById('slogan');
|
||
if (slogan) slogan.textContent = t('SIM OTA with a Human Face');
|
||
}
|
||
|
||
// Init language
|
||
const userLang = (navigator.language || '').split('-')[0];
|
||
currentLang = localStorage.getItem('lang') || (userLang === 'ru' ? 'ru' : 'en');
|
||
document.getElementById('lang-btn').textContent = currentLang.toUpperCase();
|
||
updateHelpLink();
|
||
|
||
cardsLoad();
|
||
|
||
if (localStorage.getItem('theme') === 'dark' ||
|
||
(!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||
document.documentElement.classList.add('dark');
|
||
}
|
||
function toggleTheme() {
|
||
const html = document.documentElement;
|
||
html.classList.toggle('dark');
|
||
localStorage.setItem('theme', html.classList.contains('dark') ? 'dark' : 'light');
|
||
document.getElementById('theme-btn').textContent = html.classList.contains('dark') ? '☀️' : '🌙';
|
||
}
|
||
document.getElementById('theme-btn').textContent = document.documentElement.classList.contains('dark') ? '☀️' : '🌙';
|
||
updateConvFields();
|
||
updateSp();
|
||
updateSpKic();
|
||
updateSpKid();
|
||
updateSimUsimFields('sim');
|
||
updateSimSelectMethod('sim');
|
||
updateSimSelectSection('sim');
|
||
toggleSimOverride('sim');
|
||
toggleSimOverride('usim');
|
||
updateP1P2Display('sim');
|
||
updateP1P2Display('usim');
|
||
updateRamFields();
|
||
translatePage();
|
||
// PWA
|
||
let deferredPrompt;
|
||
const installBtn = document.getElementById('install-btn');
|
||
if (!window.matchMedia('(display-mode: standalone)').matches) {
|
||
window.addEventListener('beforeinstallprompt', e => {
|
||
e.preventDefault();
|
||
deferredPrompt = e;
|
||
installBtn.style.display = '';
|
||
});
|
||
installBtn.addEventListener('click', async () => {
|
||
if (!deferredPrompt) return;
|
||
deferredPrompt.prompt();
|
||
const { outcome } = await deferredPrompt.userChoice;
|
||
if (outcome === 'accepted') deferredPrompt = null;
|
||
});
|
||
window.addEventListener('appinstalled', () => { installBtn.style.display = 'none'; });
|
||
}
|
||
if ('serviceWorker' in navigator) {
|
||
navigator.serviceWorker.register('sw.js');
|
||
}
|
||
</script>
|
||
|
||
</body>
|
||
</html> |