Files
otaman/frontend/index.html
T
catarrh 30969f8f04 profiler editor: keep record/content fields visible
- Seed one empty record row when record content is created or reset
  (filetype change or Contents None->Exact/Mask) via profilerEmptyRecordContent
- Add per-record delete and '+ Record' add controls so the record view is
  always editable; delete is hidden on the last remaining row

SW cache v47.
2026-09-09 23:46:04 +03:00

7622 lines
362 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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.26</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="scp80">SCP80</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="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 id="chain-sim-rows"></div>
<div class="flex flex-wrap gap-1 mb-3">
<button onclick="chainAddRow('chain-sim','select')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ SELECT</button>
<button onclick="chainAddRow('chain-sim','read-record')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ READ RECORD</button>
<button onclick="chainAddRow('chain-sim','read-binary')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ READ BINARY</button>
<button onclick="chainAddRow('chain-sim','update-record')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ UPDATE RECORD</button>
<button onclick="chainAddRow('chain-sim','update-binary')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ UPDATE BINARY</button>
<button onclick="chainAddRow('chain-sim','erase-binary')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ ERASE BINARY</button>
<button onclick="chainAddRow('chain-sim','activate-file')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ ACTIVATE</button>
<button onclick="chainAddRow('chain-sim','deactivate-file')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ DEACTIVATE</button>
<button onclick="chainAddRow('chain-sim','verify')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ VERIFY PIN</button>
<button onclick="chainAddRow('chain-sim','change')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ CHANGE PIN</button>
<button onclick="chainAddRow('chain-sim','disable')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ DISABLE PIN</button>
<button onclick="chainAddRow('chain-sim','enable')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ ENABLE PIN</button>
<button onclick="chainAddRow('chain-sim','unblock')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ UNBLOCK PIN</button>
<button onclick="chainAddRow('chain-sim','get-response')" class="px-2 py-1 text-xs bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded hover:bg-gray-300 dark:hover:bg-gray-600">+ GET RESPONSE</button>
</div>
<textarea id="chain-sim-preview" 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" placeholder="Chain preview — add commands above"></textarea>
<button onclick="packToSp('chain-sim-preview')" id="chain-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 id="chain-usim-rows"></div>
<div class="flex flex-wrap gap-1 mb-3">
<button onclick="chainAddRow('chain-usim','select')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ SELECT</button>
<button onclick="chainAddRow('chain-usim','read-record')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ READ RECORD</button>
<button onclick="chainAddRow('chain-usim','read-binary')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ READ BINARY</button>
<button onclick="chainAddRow('chain-usim','update-record')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ UPDATE RECORD</button>
<button onclick="chainAddRow('chain-usim','update-binary')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ UPDATE BINARY</button>
<button onclick="chainAddRow('chain-usim','activate-file')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ ACTIVATE</button>
<button onclick="chainAddRow('chain-usim','deactivate-file')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ DEACTIVATE</button>
<button onclick="chainAddRow('chain-usim','verify')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ VERIFY PIN</button>
<button onclick="chainAddRow('chain-usim','change')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ CHANGE PIN</button>
<button onclick="chainAddRow('chain-usim','disable')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ DISABLE PIN</button>
<button onclick="chainAddRow('chain-usim','enable')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ ENABLE PIN</button>
<button onclick="chainAddRow('chain-usim','unblock')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ UNBLOCK PIN</button>
<button onclick="chainAddRow('chain-usim','get-response')" class="px-2 py-1 text-xs bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded hover:bg-gray-300 dark:hover:bg-gray-600">+ GET RESPONSE</button>
</div>
<textarea id="chain-usim-preview" 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" placeholder="Chain preview — add commands above"></textarea>
<button onclick="packToSp('chain-usim-preview')" id="chain-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 id="chain-ram-rows"></div>
<div class="flex flex-wrap gap-1 mb-3">
<button onclick="chainAddRow('chain-ram','install-load')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ INSTALL [for load]</button>
<button onclick="chainAddRow('chain-ram','install-install')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ INSTALL [for install]</button>
<button onclick="chainAddRow('chain-ram','install-make-sel')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ INSTALL [for make selectable]</button>
<button onclick="chainAddRow('chain-ram','install-reg-update')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ INSTALL [for registry update]</button>
<button onclick="chainAddRow('chain-ram','install-extradition')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ INSTALL [for extradition]</button>
<button onclick="chainAddRow('chain-ram','load')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ LOAD</button>
<button onclick="chainAddRow('chain-ram','delete')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ DELETE</button>
<button onclick="chainAddRow('chain-ram','get-status')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ GET STATUS</button>
<button onclick="chainAddRow('chain-ram','get-data')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ GET DATA</button>
<button onclick="chainAddRow('chain-ram','store-data')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ STORE DATA</button>
<button onclick="chainAddRow('chain-ram','set-status')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ SET STATUS</button>
<button onclick="chainAddRow('chain-ram','ext-auth')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ EXTERNAL AUTH</button>
<button onclick="chainAddRow('chain-ram','int-auth')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ INTERNAL AUTH</button>
<button onclick="chainAddRow('chain-ram','get-response')" class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">+ GET RESPONSE</button>
</div>
<textarea id="chain-ram-preview" 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" placeholder="Chain preview — add commands above"></textarea>
<button onclick="packToSp('chain-ram-preview')" id="chain-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-scp80" class="tab-content hidden">
<div class="flex gap-2 mb-3">
<button class="scp80-subtab px-4 py-1.5 text-sm rounded-full bg-blue-600 text-white" data-scp80-sub="sp" onclick="scp80SwitchSubtab('sp')" data-l10n="Secured packet">Secured packet</button>
<button class="scp80-subtab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-scp80-sub="cards" onclick="scp80SwitchSubtab('cards')" data-l10n="Cards">Cards</button>
<button class="scp80-subtab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-scp80-sub="ram" onclick="scp80SwitchSubtab('ram')" data-l10n="RAM">RAM</button>
</div>
<div id="scp80-sub-sp">
<div class="mb-3">
<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 gap-2 mb-3">
<div class="flex-1"> <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="flex-1"> <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>
<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 (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="flex-1"> <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>
<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" oninput="spInvalidate()" 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" oninput="spInvalidate()" 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="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" oninput="spInvalidate()" 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">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" oninput="spInvalidate()" 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 class="flex-1"> <label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">Padding byte</label>
<select id="sp-padding" onchange="spInvalidate()" 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>
<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" oninput="spInvalidate()" 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>
<div id="sp-por-status" class="mt-2 text-base font-semibold font-mono hidden"></div>
<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-send-result" class="mt-2 text-xs font-mono hidden"></div>
<div id="sp-verify-result" class="mt-2 text-xs font-mono hidden"></div>
</div>
<div id="scp80-sub-cards" class="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 &amp; 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="scp80-sub-ram" class="hidden">
<p class="text-xs text-gray-500 dark:text-slate-400 mb-3" data-l10n="RAM operations perform atomic GlobalPlatform commands over SCP80. Card keys are taken from the saved preset.">RAM operations perform atomic GlobalPlatform commands over SCP80. Card keys are taken from the saved preset.</p>
<div class="mb-3">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Card preset">Card preset</label>
<select id="ram-card-sel" onchange="ramApplyCard(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="mb-3 flex gap-2 items-end">
<div class="flex-1">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Operation">Operation</label>
<select id="ram-op" onchange="ramOpChanged()" 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="explore" data-l10n="Explore Card (all GP data)">Explore Card (all GP data)</option>
<option value="install-cap" data-l10n="Install Package (.cap file)">Install Package (.cap file)</option>
</select>
</div>
<button onclick="ramExecute()" class="px-5 py-1.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700" data-l10n="Execute">Execute</button>
</div>
<div id="ram-install-params" class="hidden mb-3">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="CAP file (max 48 kB)">CAP file (max 48 kB)</label>
<input type="file" id="ram-cap-file" accept=".cap,.zip" class="w-full text-sm text-gray-600 dark:text-slate-300">
<div id="ram-cap-info" class="text-xs text-gray-500 mt-1 hidden"></div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300 mt-2" data-l10n="SD AID (empty = ISD)">SD AID (empty = ISD)</label>
<input id="ram-sd-aid" 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="A000000003000000" maxlength="32">
<div id="rc-priv-row" class="mb-3 mt-2">
<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="rc-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="rc-priv-b1" value="80" onchange="updateRcPriv()"> Security Domain</label>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b2" value="80" onchange="updateRcPriv()"> Trusted Path</label>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b1" value="40" onchange="updateRcPriv()"> DAP Verification</label>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b2" value="40" onchange="updateRcPriv()"> Authorized Management</label>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b1" value="20" onchange="updateRcPriv()"> Delegated Management</label>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b2" value="20" onchange="updateRcPriv()"> Token Verification</label>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b1" value="10" onchange="updateRcPriv()"> Card Lock</label>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b2" value="10" onchange="updateRcPriv()"> Global Delete</label>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b1" value="08" onchange="updateRcPriv()"> Card Terminate</label>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b2" value="08" onchange="updateRcPriv()"> Global Lock</label>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b1" value="04" onchange="updateRcPriv()"> Card Reset</label>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b2" value="04" onchange="updateRcPriv()"> Global Registry</label>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b1" value="02" onchange="updateRcPriv()"> CVM Management</label>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b1" value="01" onchange="updateRcPriv()"> Mandated DAP Verification</label>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b2" value="02" onchange="updateRcPriv()"> Final Application</label>
<div></div>
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b3" value="80" onchange="updateRcPriv()"> Receipt Generation</label>
</div>
</div>
<div id="rc-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="rc-toolkit-enable" type="checkbox" class="rounded border-gray-300 dark:border-slate-600 dark:bg-slate-800" onchange="updateRcToolkit()">
SIM/UICC Toolkit parameters
</label>
</div>
<div id="rc-toolkit-body" class="hidden">
<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="rc-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="rc-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="rc-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="rc-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="rc-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="rc-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="rc-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="rc-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="rc-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="rc-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="rc-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="rc-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="rc-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="rc-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="rc-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="rc-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>
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Install parameters (hex, optional)">Install parameters (hex, optional)</label>
<input id="ram-install-params-hex" 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="C9 TLV">
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400 mt-2" data-l10n="STK parameters (hex, optional)">STK parameters (hex, optional)</label>
<input id="ram-stk-params" 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="CA TLV">
<label class="flex items-center gap-2 mt-2">
<input type="checkbox" id="ram-make-sel" checked> <span class="text-sm" data-l10n="Make selectable">Make selectable</span>
</label>
</div>
<div id="ram-progress" class="hidden mb-3">
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-slate-400">
<div class="animate-spin w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
<span id="ram-progress-text" data-l10n="Working...">Working...</span>
</div>
</div>
<div id="ram-result" class="mt-2 text-base font-semibold font-mono hidden"></div>
<div id="ram-explorer" class="mt-2 text-xs font-mono hidden"></div>
<div id="ram-steps" class="mt-2 text-xs font-mono whitespace-pre-wrap hidden"></div>
</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-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="profiler" data-l10n="Profiler">Profiler</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>
</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 &amp; 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-profiler" class="hidden">
<div id="profiler-list">
<div class="flex flex-wrap gap-2 mb-3">
<button onclick="profilerNew()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="New profile">New profile</button>
<button onclick="profilerFromCard()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Profile from card">Profile from card</button>
<button onclick="document.getElementById('profiler-import-file').click()" class="px-2.5 py-1 text-sm rounded bg-gray-600 text-white hover:bg-gray-700" data-l10n="Import profile">Import profile</button>
<input type="file" id="profiler-import-file" accept=".json,application/json" class="hidden" onchange="profilerImportFile(this)">
</div>
<div id="profiler-list-body"></div>
</div>
<div id="profiler-editor" class="hidden">
<div class="flex flex-wrap gap-2 items-center mb-3">
<button onclick="profilerBackToList()" class="px-2.5 py-1 text-sm 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="Back to list">Back to list</button>
<input id="profiler-name" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="Profile name">
<button onclick="profilerAddRule()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Add rule">Add rule</button>
<button onclick="profilerSaveProfile()" 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="profiler-rules"></div>
</div>
<div id="profiler-results" class="hidden">
<div class="flex flex-wrap gap-2 items-center mb-3">
<button onclick="profilerBackToList()" class="px-2.5 py-1 text-sm 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="Back to list">Back to list</button>
<span id="profiler-results-title" class="text-sm font-semibold"></span>
</div>
<div id="profiler-progress" class="mb-2 text-base font-mono text-gray-600 dark:text-slate-400"></div>
<div id="profiler-summary" class="mb-2 text-sm"></div>
<div id="profiler-report"></div>
</div>
</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="STK menu">STK menu</div>
<div class="flex items-center gap-2 mb-3">
<button id="stk-menu-btn" onclick="stkMenuOpen()" class="px-2.5 py-1 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700" style="display:none">STK: <span id="stk-menu-title"></span></button>
<span id="stk-menu-none" class="text-xs text-gray-500 dark:text-slate-400" style="display:none" data-l10n="No menu set by the card">No menu set by the card</span>
</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="profiler-scan-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 class="text-lg font-semibold mb-4 text-gray-800 dark:text-slate-200" data-l10n="Profile from card">Profile from card</h3>
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Profile name">Profile name</label>
<input id="profiler-scan-name" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2 dark:bg-slate-800 mb-3" placeholder="Profile name">
<div class="mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Ignore contents of:">Ignore contents of:</div>
<div id="profiler-scan-ignore" class="space-y-1 mb-3 max-h-56 overflow-auto"></div>
<div id="profiler-scan-error" class="text-xs text-red-500 hidden mb-2"></div>
<div class="flex gap-2 justify-end">
<button onclick="profilerScanCancel()" 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="profiler-scan-btn" onclick="profilerScanStart()" class="px-4 py-1.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Scan">Scan</button>
</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 === 'scp80') setHelpAnchor(scp80HelpAnchor);
else if (name === 'response') setHelpAnchor('response-parser');
else if (name === 'pysim') setHelpAnchor(pysimHelpAnchor);
}
let scp80HelpAnchor = 'secured-packet';
function scp80SwitchSubtab(name) {
document.querySelectorAll('.scp80-subtab').forEach(btn => {
const isActive = btn.dataset.scp80Sub === 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);
});
document.getElementById('scp80-sub-sp').classList.toggle('hidden', name !== 'sp');
document.getElementById('scp80-sub-cards').classList.toggle('hidden', name !== 'cards');
document.getElementById('scp80-sub-ram').classList.toggle('hidden', name !== 'ram');
if (name === 'cards') cardsRender();
if (name === 'ram') ramRender();
scp80HelpAnchor = { sp: 'secured-packet', cards: 'cards', ram: 'ram' }[name] || 'secured-packet';
setHelpAnchor(scp80HelpAnchor);
}
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);
});
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));
});
function updateRcPriv() {
let b1 = 0, b2 = 0, b3 = 0;
document.querySelectorAll('.rc-priv-b1:checked').forEach(cb => b1 |= parseInt(cb.value, 16));
document.querySelectorAll('.rc-priv-b2:checked').forEach(cb => b2 |= parseInt(cb.value, 16));
document.querySelectorAll('.rc-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('rc-priv').value = hex;
}
function updateRcToolkit() {
const checked = document.getElementById('rc-toolkit-enable').checked;
document.getElementById('rc-toolkit-body').classList.toggle('hidden', !checked);
updateRcTkMode();
updateStkParamsHex();
}
function updateRcTkMode() {
const isSim = document.getElementById('rc-tk-mode').value === 'ca';
document.getElementById('rc-tk-ad-row').style.display = isSim ? '' : 'none';
document.getElementById('rc-tk-services-row').style.display = isSim ? 'none' : '';
}
document.getElementById('rc-tk-mode').addEventListener('change', () => { updateRcTkMode(); updateStkParamsHex(); });
function buildRcToolkitParams() {
if (!document.getElementById('rc-toolkit-enable').checked) return '';
const tkMode = document.getElementById('rc-tk-mode').value;
const priority = parseInt(document.getElementById('rc-tk-priority').value) || 0;
const timers = parseInt(document.getElementById('rc-tk-timers').value) || 0;
const textLen = parseInt(document.getElementById('rc-tk-textlen').value) || 0;
const menus = parseInt(document.getElementById('rc-tk-menus').value) || 0;
const firstPos = parseInt(document.getElementById('rc-tk-firstpos').value) || 0;
const firstId = (document.getElementById('rc-tk-firstid').value || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2) || '00';
const lastPos = parseInt(document.getElementById('rc-tk-lastpos').value) || 0;
const lastId = (document.getElementById('rc-tk-lastid').value || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2) || '00';
const channels = parseInt(document.getElementById('rc-tk-channels').value) || 0;
const msl = document.getElementById('rc-tk-msl').value;
const tar = (document.getElementById('rc-tk-tar').value || '').replace(/[^0-9a-fA-F]/g, '');
const adRaw = (document.getElementById('rc-tk-ad').value || '').replace(/[^0-9a-fA-F]/g, '');
const ad = adRaw.padStart(2, '0').slice(0, 2);
const services = parseInt(document.getElementById('rc-tk-services').value) || 0;
if (tar && tar.length % 6 !== 0) return null;
if (parseInt(firstId, 16) > 0x7F || parseInt(lastId, 16) > 0x7F) 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;
}
}
function updateStkParamsHex() {
const tk = buildRcToolkitParams();
document.getElementById('ram-stk-params').value = tk || '';
}
function updateInstallParamsHex() {
document.getElementById('ram-install-params-hex').value = 'C900';
}
// ===== 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 escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// ===== Chain Builder =====
// Row-based APDU chain builder for SIM RFM, USIM RFM, and RAM/GP sub-tabs.
// Each chain has a list of rows; each row = one APDU command with its own fields.
// Chain preview = concatenation of all row hexes, with Le stripping when
// a Case 4 command is followed by GET RESPONSE (ETSI TS 102 226 compact).
const _chains = {};
const CHAIN_CMDS_SIM = [
{value:'select',label:'SELECT'},{value:'read-record',label:'READ RECORD'},
{value:'read-binary',label:'READ BINARY'},{value:'update-record',label:'UPDATE RECORD'},
{value:'update-binary',label:'UPDATE BINARY'},{value:'erase-binary',label:'ERASE BINARY'},
{value:'activate-file',label:'ACTIVATE FILE'},{value:'deactivate-file',label:'DEACTIVATE FILE'},
{value:'verify',label:'VERIFY PIN'},{value:'change',label:'CHANGE PIN'},
{value:'disable',label:'DISABLE PIN'},{value:'enable',label:'ENABLE PIN'},
{value:'unblock',label:'UNBLOCK PIN'},{value:'get-response',label:'GET RESPONSE'},
];
const CHAIN_CMDS_USIM = [
{value:'select',label:'SELECT'},{value:'read-record',label:'READ RECORD'},
{value:'read-binary',label:'READ BINARY'},{value:'update-record',label:'UPDATE RECORD'},
{value:'update-binary',label:'UPDATE BINARY'},
{value:'activate-file',label:'ACTIVATE FILE'},{value:'deactivate-file',label:'DEACTIVATE FILE'},
{value:'verify',label:'VERIFY PIN'},{value:'change',label:'CHANGE PIN'},
{value:'disable',label:'DISABLE PIN'},{value:'enable',label:'ENABLE PIN'},
{value:'unblock',label:'UNBLOCK PIN'},{value:'get-response',label:'GET RESPONSE'},
];
const CHAIN_CMDS_RAM = [
{value:'install-load',label:'INSTALL [for load]'},
{value:'install-install',label:'INSTALL [for install]'},
{value:'install-make-sel',label:'INSTALL [for make selectable]'},
{value:'install-reg-update',label:'INSTALL [for registry update]'},
{value:'install-extradition',label:'INSTALL [for extradition]'},
{value:'load',label:'LOAD'},{value:'delete',label:'DELETE'},
{value:'get-status',label:'GET STATUS'},{value:'get-data',label:'GET DATA'},
{value:'store-data',label:'STORE DATA'},{value:'set-status',label:'SET STATUS'},
{value:'ext-auth',label:'EXTERNAL AUTHENTICATE'},
{value:'int-auth',label:'INTERNAL AUTHENTICATE'},
{value:'get-response',label:'GET RESPONSE'},
];
function chainInit(id) { if (!_chains[id]) _chains[id] = {rows: []}; }
function chainAddRow(id, cmd) { chainInit(id); _chains[id].rows.push({cmd: cmd, fields: {}}); chainRender(id); }
function chainDeleteRow(id, idx) { chainInit(id); _chains[id].rows.splice(idx, 1); chainRender(id); }
function chainUpdateCmd(id, idx, cmd) { chainInit(id); _chains[id].rows[idx].cmd = cmd; _chains[id].rows[idx].fields = {}; chainRender(id); }
function chainUpdateField(id, idx, field, value) {
chainInit(id);
_chains[id].rows[idx].fields[field] = value;
const h = document.getElementById(id + '-row-' + idx + '-hex');
if (h) h.value = chainBuildRowHex(id, idx);
const p = document.getElementById(id + '-preview');
if (p) p.value = chainBuildHex(id);
const b = document.getElementById(id + '-pack-btn');
if (b) b.disabled = !chainBuildHex(id);
}
function chainRender(chainId) {
chainInit(chainId);
const container = document.getElementById(chainId + '-rows');
const preview = document.getElementById(chainId + '-preview');
const packBtn = document.getElementById(chainId + '-pack-btn');
if (!container) return;
const rows = _chains[chainId].rows;
container.innerHTML = '';
rows.forEach(function(row, idx) {
const div = document.createElement('div');
div.className = 'mb-3 p-3 border border-gray-200 dark:border-slate-600 rounded';
div.innerHTML = chainRenderRow(chainId, idx, row);
container.appendChild(div);
});
const hex = chainBuildHex(chainId);
if (preview) preview.value = hex;
if (packBtn) packBtn.disabled = !hex;
}
function chainRenderRow(chainId, idx, row) {
const isRam = chainId === 'chain-ram';
const cmds = isRam ? CHAIN_CMDS_RAM : (chainId === 'chain-usim' ? CHAIN_CMDS_USIM : CHAIN_CMDS_SIM);
const cmdOpts = cmds.map(function(c) {
return '<option value="' + c.value + '"' + (c.value === row.cmd ? ' selected' : '') + '>' + c.label + '</option>';
}).join('');
const fieldsHtml = chainRowFieldsHtml(chainId, idx, row);
const hex = chainBuildRowHex(chainId, idx);
return '<div class="flex items-center gap-2 mb-2">' +
'<select onchange="chainUpdateCmd(\'' + chainId + '\',' + idx + ',this.value)" class="flex-shrink-0 w-52 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">' + cmdOpts + '</select>' +
'<button onclick="chainDeleteRow(\'' + chainId + '\',' + idx + ')" class="text-red-500 hover:text-red-700 text-lg font-bold px-1" title="Delete">&times;</button>' +
'<input id="' + chainId + '-row-' + idx + '-hex" readonly value="' + escHtml(hex) + '" class="flex-1 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-50 dark:bg-slate-900">' +
'</div>' +
'<div class="pl-4">' + fieldsHtml + '</div>';
}
function chainRowFieldsHtml(chainId, idx, row) {
var f = row.fields;
function uf(field) { return "chainUpdateField('" + chainId + "'," + idx + ",'" + field + "',this.value)"; }
if (chainId === 'chain-sim' || chainId === 'chain-usim') return chainSimFieldsHtml(chainId, idx, f, uf);
return chainRamFieldsHtml(idx, row, f, uf);
}
// ===== SIM/USIM field rendering =====
function chainSimFieldsHtml(chainId, idx, f, uf) {
var cmd = (_chains[chainId] && _chains[chainId].rows[idx]) ? _chains[chainId].rows[idx].cmd : '';
if (cmd === 'select') {
var method = f.method || 'fid';
var silent = chainId === 'chain-usim' && f.silent;
var html = '<div class="grid grid-cols-2 gap-2 mb-2"><div>' +
'<label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Method</label>' +
'<select onchange="' + uf('method') + ';chainRender(\'' + chainId + '\')" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="fid"' + (method === 'fid' ? ' selected' : '') + '>By FID</option>' +
'<option value="path"' + (method === 'path' ? ' selected' : '') + '>By path</option>' +
'<option value="dfname"' + (method === 'dfname' ? ' selected' : '') + '>By AID</option>' +
'<option value="chain"' + (method === 'chain' ? ' selected' : '') + '>Chain</option>' +
'</select></div>';
if (chainId === 'chain-usim') {
html += '<div class="flex items-end pb-0.5"><label class="flex items-center gap-1.5 text-xs">' +
'<input type="checkbox"' + (silent ? ' checked' : '') + ' onchange="chainUpdateField(\'chain-usim\',' + idx + ',\'silent\',this.checked);chainRender(\'chain-usim\')" class="rounded">' +
'Silent (P2=0C)</label></div>';
}
html += '</div>';
if (method === 'fid') {
html += '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">FID (2 bytes)</label>' +
'<input value="' + escHtml(f.fid || '') + '" oninput="' + uf('fid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="6FC5" maxlength="4"></div>';
}
if (method === 'path') {
html += '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Path (hex)</label>' +
'<input value="' + escHtml(f.path || '') + '" oninput="' + uf('path') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="7FFF6FC5">';
if (chainId === 'chain-usim') {
html += '<div class="flex gap-3 mt-1 text-xs"><label class="flex items-center gap-1"><input type="radio" name="ch-usim-' + idx + '-base" value="mf"' + ((f.base || 'mf') === 'mf' ? ' checked' : '') + ' onchange="chainUpdateField(\'chain-usim\',' + idx + ',\'base\',\'mf\')"> from MF</label>' +
'<label class="flex items-center gap-1"><input type="radio" name="ch-usim-' + idx + '-base" value="df"' + (f.base === 'df' ? ' checked' : '') + ' onchange="chainUpdateField(\'chain-usim\',' + idx + ',\'base\',\'df\')"> from current DF</label></div>';
}
html += '</div>';
}
if (method === 'dfname') {
html += '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">AID (hex)</label>' +
'<input value="' + escHtml(f.dfname || '') + '" oninput="' + uf('dfname') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div>';
}
if (method === 'chain') {
html += '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">FID chain (comma-separated)</label>' +
'<input value="' + escHtml(f.chain || '') + '" oninput="' + uf('chain') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="3F00,2FE2,C0"></div>';
}
return html;
}
if (cmd === 'read-record') {
var rm = f.recMode || '04';
return '<div class="grid grid-cols-3 gap-2">' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Record #</label>' +
'<input type="number" min="1" max="255" value="' + escHtml(f.record || '1') + '" oninput="' + uf('record') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800"></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">P2 (mode)</label>' +
'<select onchange="' + uf('recMode') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="04"' + (rm === '04' ? ' selected' : '') + '>Absolute (04)</option>' +
'<option value="02"' + (rm === '02' ? ' selected' : '') + '>Next (02)</option>' +
'<option value="03"' + (rm === '03' ? ' selected' : '') + '>Previous (03)</option></select></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Le</label>' +
'<input value="' + escHtml(f.le || '00') + '" oninput="' + uf('le') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="00" maxlength="2"></div></div>';
}
if (cmd === 'read-binary') {
return '<div class="grid grid-cols-2 gap-2">' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Offset (hex)</label>' +
'<input value="' + escHtml(f.offset || '0000') + '" oninput="' + uf('offset') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="0000" maxlength="4"></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Le</label>' +
'<input value="' + escHtml(f.le || '00') + '" oninput="' + uf('le') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="00" maxlength="2"></div></div>';
}
if (cmd === 'update-record') {
var rm2 = f.recMode || '04';
return '<div class="grid grid-cols-2 gap-2 mb-2">' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Record #</label>' +
'<input type="number" min="1" max="255" value="' + escHtml(f.record || '1') + '" oninput="' + uf('record') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800"></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">P2 (mode)</label>' +
'<select onchange="' + uf('recMode') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="04"' + (rm2 === '04' ? ' selected' : '') + '>Absolute (04)</option>' +
'<option value="02"' + (rm2 === '02' ? ' selected' : '') + '>Next (02)</option>' +
'<option value="03"' + (rm2 === '03' ? ' selected' : '') + '>Previous (03)</option></select></div></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Data (hex)</label>' +
'<textarea oninput="' + uf('data') + '" rows="2" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800">' + escHtml(f.data || '') + '</textarea></div>';
}
if (cmd === 'update-binary') {
return '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Offset (hex)</label>' +
'<input value="' + escHtml(f.offset || '0000') + '" oninput="' + uf('offset') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="0000" maxlength="4"></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Data (hex)</label>' +
'<textarea oninput="' + uf('data') + '" rows="2" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800">' + escHtml(f.data || '') + '</textarea></div>';
}
if (cmd === 'erase-binary') {
return '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Offset (hex)</label>' +
'<input value="' + escHtml(f.offset || '0000') + '" oninput="' + uf('offset') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="0000" maxlength="4"></div>';
}
if (cmd === 'activate-file' || cmd === 'deactivate-file') {
var target = f.target || 'current';
var html2 = '<div class="grid grid-cols-2 gap-2"><div>' +
'<label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Target</label>' +
'<select onchange="' + uf('target') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="current"' + (target === 'current' ? ' selected' : '') + '>Current EF (case 1)</option>' +
'<option value="fid"' + (target === 'fid' ? ' selected' : '') + '>By FID</option>' +
'<option value="mfpath"' + (target === 'mfpath' ? ' selected' : '') + '>Path from MF</option>' +
'<option value="dfpath"' + (target === 'dfpath' ? ' selected' : '') + '>Path from current DF</option></select></div>';
if (target !== 'current') {
html2 += '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">File (hex)</label>' +
'<input value="' + escHtml(f.file || '') + '" oninput="' + uf('file') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="6FC5 / 7FFF6FC5"></div>';
}
html2 += '</div>';
return html2;
}
if (cmd === 'verify' || cmd === 'disable' || cmd === 'enable') {
var pin = f.pin || '01';
return '<div class="grid grid-cols-2 gap-2">' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">PIN</label>' +
'<select onchange="' + uf('pin') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="01"' + (pin === '01' ? ' selected' : '') + '>PIN1 (01)</option>' +
'<option value="02"' + (pin === '02' ? ' selected' : '') + '>PIN2 (02)</option></select></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">PIN value</label>' +
'<input value="' + escHtml(f.pinVal || '') + '" oninput="' + uf('pinVal') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="1234" maxlength="8"></div></div>';
}
if (cmd === 'change' || cmd === 'unblock') {
var pin2 = f.pin || '01';
return '<div class="grid grid-cols-3 gap-2">' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">PIN</label>' +
'<select onchange="' + uf('pin') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="01"' + (pin2 === '01' ? ' selected' : '') + '>PIN1 (01)</option>' +
'<option value="02"' + (pin2 === '02' ? ' selected' : '') + '>PIN2 (02)</option></select></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + (cmd === 'unblock' ? 'Unblock PIN' : 'Old PIN') + '</label>' +
'<input value="' + escHtml(f.pinOld || '') + '" oninput="' + uf('pinOld') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="1234" maxlength="8"></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">New PIN</label>' +
'<input value="' + escHtml(f.pinNew || '') + '" oninput="' + uf('pinNew') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="5678" maxlength="8"></div></div>';
}
if (cmd === 'get-response') {
return '<div class="text-xs text-gray-500 dark:text-slate-400 italic">No parameters &mdash; CLA=C0 INS=C0 P1=00 P2=00</div>';
}
return '';
}
// ===== RAM/GP field rendering =====
function chainRamFieldsHtml(idx, row, f, uf) {
var cmd = row.cmd;
if (cmd === 'install-load') {
return '<div class="grid grid-cols-2 gap-2 mb-2">' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Load File AID</label>' +
'<input value="' + escHtml(f.loadFileAid || '') + '" oninput="' + uf('loadFileAid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">SD AID</label>' +
'<input value="' + escHtml(f.sdAid || '') + '" oninput="' + uf('sdAid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Load data (hex)</label>' +
'<textarea oninput="' + uf('loadData') + '" rows="2" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800">' + escHtml(f.loadData || '') + '</textarea></div>';
}
if (cmd === 'install-install') {
var html = '<div class="grid grid-cols-3 gap-2 mb-2">' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Application / Instance AID</label>' +
'<input value="' + escHtml(f.aid || '') + '" oninput="' + uf('aid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Load File AID / Package AID</label>' +
'<input value="' + escHtml(f.elfAid || '') + '" oninput="' + uf('elfAid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Executable Module AID / Applet Class AID</label>' +
'<input value="' + escHtml(f.modAid || '') + '" oninput="' + uf('modAid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div></div>';
// Privileges
html += chainRamPrivHtml(idx, f, uf);
// Toolkit
html += chainRamToolkitHtml(idx, f, uf);
return html;
}
if (cmd === 'install-make-sel') {
return '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Application / Instance AID</label>' +
'<input value="' + escHtml(f.aid || '') + '" oninput="' + uf('aid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div>' +
chainRamPrivHtml(idx, f, uf);
}
if (cmd === 'install-reg-update') {
return '<div class="grid grid-cols-2 gap-2 mb-2">' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">SD AID</label>' +
'<input value="' + escHtml(f.sdAid || '') + '" oninput="' + uf('sdAid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Application / Instance AID</label>' +
'<input value="' + escHtml(f.aid || '') + '" oninput="' + uf('aid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div></div>' +
chainRamPrivHtml(idx, f, uf);
}
if (cmd === 'install-extradition') {
return '<div class="grid grid-cols-2 gap-2">' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">SD AID</label>' +
'<input value="' + escHtml(f.sdAid || '') + '" oninput="' + uf('sdAid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Application / Instance AID</label>' +
'<input value="' + escHtml(f.aid || '') + '" oninput="' + uf('aid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div></div>';
}
if (cmd === 'load') {
return '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Data (hex)</label>' +
'<textarea oninput="' + uf('data') + '" rows="2" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800">' + escHtml(f.data || '') + '</textarea></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Block number</label>' +
'<input type="number" min="0" value="' + escHtml(f.block || '0') + '" oninput="' + uf('block') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800"></div>';
}
if (cmd === 'delete') {
var dm = f.delMode || '00';
return '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Application / Instance AID</label>' +
'<input value="' + escHtml(f.aid || '') + '" oninput="' + uf('aid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">DELETE mode</label>' +
'<select onchange="' + uf('delMode') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="00"' + (dm === '00' ? ' selected' : '') + '>AID only (00)</option>' +
'<option value="80"' + (dm === '80' ? ' selected' : '') + '>AID + related objects (80)</option></select></div>';
}
if (cmd === 'get-status') {
var gm = f.gsMode || '80';
var gp = f.gsP2 || '00';
return '<div class="grid grid-cols-2 gap-2">' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">GET STATUS mode</label>' +
'<select onchange="' + uf('gsMode') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="80"' + (gm === '80' ? ' selected' : '') + '>ISD (80)</option>' +
'<option value="40"' + (gm === '40' ? ' selected' : '') + '>Applications and SSD (40)</option>' +
'<option value="20"' + (gm === '20' ? ' selected' : '') + '>Executable Load Files (20)</option>' +
'<option value="10"' + (gm === '10' ? ' selected' : '') + '>ELF and modules (10)</option></select></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">GET STATUS format</label>' +
'<select onchange="' + uf('gsP2') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="02"' + (gp === '02' ? ' selected' : '') + '>02 &mdash; First/all, TLV format</option>' +
'<option value="03"' + (gp === '03' ? ' selected' : '') + '>03 &mdash; Next, TLV format</option>' +
'<option value="00"' + (gp === '00' ? ' selected' : '') + '>00 &mdash; First/all, raw format</option></select></div></div>';
}
if (cmd === 'get-data') {
var tag = f.tag || '5F50';
return '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">GET DATA tag</label>' +
'<select onchange="chainUpdateField(\'chain-ram\',' + idx + ',\'tag\',this.value)" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800 mb-1">' +
'<option value=""' + (!tag ? ' selected' : '') + '>&mdash; select preset &mdash;</option>' +
'<option value="42"' + (tag === '42' ? ' selected' : '') + '>42 &mdash; IIN</option>' +
'<option value="45"' + (tag === '45' ? ' selected' : '') + '>45 &mdash; CIN</option>' +
'<option value="66"' + (tag === '66' ? ' selected' : '') + '>66 &mdash; Card Data</option>' +
'<option value="67"' + (tag === '67' ? ' selected' : '') + '>67 &mdash; Card Capability</option>' +
'<option value="E0"' + (tag === 'E0' ? ' selected' : '') + '>E0 &mdash; Key Info Template</option>' +
'<option value="D3"' + (tag === 'D3' ? ' selected' : '') + '>D3 &mdash; Current Security Level</option>' +
'<option value="2F00"' + (tag === '2F00' ? ' selected' : '') + '>2F00 &mdash; List of Applications</option>' +
'<option value="FF21"' + (tag === 'FF21' ? ' selected' : '') + '>FF21 &mdash; Extended Card Resources</option>' +
'<option value="5F50"' + (tag === '5F50' ? ' selected' : '') + '>5F50 &mdash; SD Manager URL</option>' +
'<option value="C1"' + (tag === 'C1' ? ' selected' : '') + '>C1 &mdash; Sequence Counter</option>' +
'<option value="C2"' + (tag === 'C2' ? ' selected' : '') + '>C2 &mdash; Confirmation Counter</option>' +
'<option value="7F21"' + (tag === '7F21' ? ' selected' : '') + '>7F21 &mdash; Certificate</option>' +
'<option value="5031"' + (tag === '5031' ? ' selected' : '') + '>5031 &mdash; Certificate info</option>' +
'</select>' +
'<input value="' + escHtml(tag) + '" oninput="' + uf('tag') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="5F50" maxlength="4"></div>';
}
if (cmd === 'store-data') {
var enc = f.enc || '00';
return '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Data (hex)</label>' +
'<textarea oninput="' + uf('data') + '" rows="2" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800">' + escHtml(f.data || '') + '</textarea></div>' +
'<div class="grid grid-cols-2 gap-2">' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Encryption</label>' +
'<select onchange="' + uf('enc') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="00"' + (enc === '00' ? ' selected' : '') + '>No encryption, more blocks</option>' +
'<option value="40"' + (enc === '40' ? ' selected' : '') + '>App-dependent, more blocks</option>' +
'<option value="80"' + (enc === '80' ? ' selected' : '') + '>No encryption, last block</option>' +
'<option value="C0"' + (enc === 'C0' ? ' selected' : '') + '>App-dependent, last block</option>' +
'<option value="E0"' + (enc === 'E0' ? ' selected' : '') + '>With encryption, last block</option></select></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Block number</label>' +
'<input type="number" min="0" value="' + escHtml(f.block || '0') + '" oninput="' + uf('block') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800"></div></div>';
}
if (cmd === 'set-status') {
var sm = f.ssMode || '80';
var ss = f.ssState || '00';
var html3 = '<div class="grid grid-cols-2 gap-2 mb-2">' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">SET STATUS type</label>' +
'<select onchange="' + uf('ssMode') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="80"' + (sm === '80' ? ' selected' : '') + '>ISD (80)</option>' +
'<option value="40"' + (sm === '40' ? ' selected' : '') + '>Application or SSD (40)</option>' +
'<option value="60"' + (sm === '60' ? ' selected' : '') + '>SD + associated apps (60)</option></select></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">State</label>' +
'<select onchange="' + uf('ssState') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="00"' + (ss === '00' ? ' selected' : '') + '>00 &mdash; Unlock</option>' +
'<option value="80"' + (ss === '80' ? ' selected' : '') + '>80 &mdash; Lock (LOCKED)</option>' +
'<option value="01"' + (ss === '01' ? ' selected' : '') + '>01 &mdash; OP_READY</option>' +
'<option value="07"' + (ss === '07' ? ' selected' : '') + '>07 &mdash; INITIALIZED</option>' +
'<option value="0F"' + (ss === '0F' ? ' selected' : '') + '>0F &mdash; SECURED</option>' +
'<option value="7F"' + (ss === '7F' ? ' selected' : '') + '>7F &mdash; CARD_LOCKED</option>' +
'<option value="FF"' + (ss === 'FF' ? ' selected' : '') + '>FF &mdash; TERMINATED</option></select></div></div>';
if (sm !== '80') {
html3 += '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Application / Instance AID</label>' +
'<input value="' + escHtml(f.aid || '') + '" oninput="' + uf('aid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div>';
}
return html3;
}
if (cmd === 'ext-auth') {
var ap = f.authP1 || '00';
return '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Data (hex) &mdash; 16 bytes</label>' +
'<input value="' + escHtml(f.data || '') + '" oninput="' + uf('data') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="32 hex chars"></div>' +
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Security level (P1)</label>' +
'<select onchange="' + uf('authP1') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="00"' + (ap === '00' ? ' selected' : '') + '>00 &mdash; No secure messaging</option>' +
'<option value="01"' + (ap === '01' ? ' selected' : '') + '>01 &mdash; C-MAC</option>' +
'<option value="03"' + (ap === '03' ? ' selected' : '') + '>03 &mdash; C-DEC + C-MAC</option></select></div>';
}
if (cmd === 'int-auth') {
return '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Data (hex) &mdash; challenge</label>' +
'<input value="' + escHtml(f.data || '') + '" oninput="' + uf('data') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="hex"></div>';
}
if (cmd === 'get-response') {
return '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Le (max bytes)</label>' +
'<input value="' + escHtml(f.le || '00') + '" oninput="' + uf('le') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="00" maxlength="2"></div>';
}
return '';
}
// ===== RAM/GP privileges sub-form (per-row) =====
function chainRamPrivHtml(idx, f, uf) {
var priv = f.priv || '00';
var b1 = parseInt(priv.substring(0, 2), 16) || 0;
var b2 = priv.length >= 4 ? parseInt(priv.substring(2, 4), 16) : 0;
var b3 = priv.length >= 6 ? parseInt(priv.substring(4, 6), 16) : 0;
var html = '<div class="mb-2"><div class="flex items-center gap-3 mb-1">' +
'<label class="text-xs font-medium text-gray-700 dark:text-slate-300">Privileges</label>' +
'<input readonly value="' + escHtml(priv) + '" class="font-mono w-16 border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-0.5 bg-gray-100 dark:bg-slate-800">' +
'</div><div class="grid grid-cols-2 gap-x-4 gap-y-0.5 text-xs">';
var CHECKS = [
['Security Domain','80','b1'],['Trusted Path','80','b2'],
['DAP Verification','40','b1'],['Authorized Management','40','b2'],
['Delegated Management','20','b1'],['Token Verification','20','b2'],
['Card Lock','10','b1'],['Global Delete','10','b2'],
['Card Terminate','08','b1'],['Global Lock','08','b2'],
['Card Reset','04','b1'],['Global Registry','04','b2'],
['CVM Management','02','b1'],['Final Application','02','b2'],
['Mandated DAP Verification','01','b1'],['',0,''],
['Receipt Generation','80','b3'],['','','']
];
CHECKS.forEach(function(c) {
if (!c[0]) return '';
var byteIdx = c[2];
var val = parseInt(c[1], 16);
var checked = (byteIdx === 'b1' && (b1 & val)) || (byteIdx === 'b2' && (b2 & val)) || (byteIdx === 'b3' && (b3 & val));
html += '<label class="flex items-center gap-1"><input type="checkbox" class="chain-priv-' + byteIdx + '-' + idx + '"' +
(checked ? ' checked' : '') + ' onchange="chainRamUpdatePriv(' + idx + ')"> ' + c[0] + '</label>';
});
html += '</div></div>';
return html;
}
function chainRamUpdatePriv(idx) {
var b1 = 0, b2 = 0, b3 = 0;
document.querySelectorAll('.chain-priv-b1-' + idx + ':checked').forEach(function(cb) { b1 |= parseInt(cb.value, 16); });
document.querySelectorAll('.chain-priv-b2-' + idx + ':checked').forEach(function(cb) { b2 |= parseInt(cb.value, 16); });
document.querySelectorAll('.chain-priv-b3-' + idx + ':checked').forEach(function(cb) { b3 |= parseInt(cb.value, 16); });
var 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();
chainUpdateField('chain-ram', idx, 'priv', hex);
}
// ===== RAM/GP toolkit sub-form (per-row) =====
function chainRamToolkitHtml(idx, f, uf) {
var enabled = f.tkEnabled;
var html = '<div class="mb-2"><label class="flex items-center gap-2 text-xs font-medium text-gray-700 dark:text-slate-300">' +
'<input type="checkbox"' + (enabled ? ' checked' : '') + ' onchange="chainUpdateField(\'chain-ram\',' + idx + ',\'tkEnabled\',this.checked);chainRender(\'chain-ram\')" class="rounded">' +
'SIM/UICC Toolkit parameters</label></div>';
if (!enabled) return html;
var tkMode = f.tkMode || 'ea';
var priority = f.tkPriority || '0';
var timers = f.tkTimers || '0';
var textLen = f.tkTextlen || '0';
var menus = f.tkMenus || '0';
var firstPos = f.tkFirstpos || '0';
var firstId = f.tkFirstid || '00';
var lastPos = f.tkLastpos || '0';
var lastId = f.tkLastid || '00';
var channels = f.tkChannels || '0';
var msl = f.tkMsl || '16';
var tar = f.tkTar || 'B00001';
var ad = f.tkAd || '';
var services = f.tkServices || '0';
html += '<div class="p-2 bg-gray-50 dark:bg-slate-900 rounded border border-gray-200 dark:border-slate-700">' +
'<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Toolkit mode</label>' +
'<select onchange="' + uf('tkMode') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="ca"' + (tkMode === 'ca' ? ' selected' : '') + '>SIM Toolkit (Tag CA)</option>' +
'<option value="ea"' + (tkMode === 'ea' ? ' selected' : '') + '>UICC Toolkit (Tag EA)</option></select></div>' +
'<div class="grid grid-cols-4 gap-x-2 gap-y-1.5 text-xs">' +
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Priority</label>' +
'<input type="number" min="0" max="255" value="' + escHtml(priority) + '" oninput="' + uf('tkPriority') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800"></div>' +
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Timers</label>' +
'<input type="number" min="0" max="255" value="' + escHtml(timers) + '" oninput="' + uf('tkTimers') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800"></div>' +
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Text length</label>' +
'<input type="number" min="0" max="255" value="' + escHtml(textLen) + '" oninput="' + uf('tkTextlen') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800"></div>' +
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Menus</label>' +
'<input type="number" min="0" max="255" value="' + escHtml(menus) + '" oninput="' + uf('tkMenus') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800"></div>' +
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">1st pos</label>' +
'<input type="number" min="0" max="255" value="' + escHtml(firstPos) + '" oninput="' + uf('tkFirstpos') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800"></div>' +
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">1st ID (hex)</label>' +
'<input value="' + escHtml(firstId) + '" oninput="' + uf('tkFirstid') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800" maxlength="2"></div>' +
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Last pos</label>' +
'<input type="number" min="0" max="255" value="' + escHtml(lastPos) + '" oninput="' + uf('tkLastpos') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800"></div>' +
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Last ID (hex)</label>' +
'<input value="' + escHtml(lastId) + '" oninput="' + uf('tkLastid') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800" maxlength="2"></div>' +
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Channels</label>' +
'<input type="number" min="0" max="255" value="' + escHtml(channels) + '" oninput="' + uf('tkChannels') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800"></div>' +
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">MSL (SPI1)</label>' +
'<select onchange="' + uf('tkMsl') + '" class="w-full border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800">' +
'<option value="00"' + (msl === '00' ? ' selected' : '') + '>00</option>' +
'<option value="11"' + (msl === '11' ? ' selected' : '') + '>11 RC/CC/DS</option>' +
'<option value="12"' + (msl === '12' ? ' selected' : '') + '>12 RC/DS/CC</option>' +
'<option value="15"' + (msl === '15' ? ' selected' : '') + '>15 RC/DS/CC+MAC</option>' +
'<option value="16"' + (msl === '16' ? ' selected' : '') + '>16 RC/DS/CC+MAC+Cipher</option>' +
'<option value="19"' + (msl === '19' ? ' selected' : '') + '>19 RC/DS/CC+MAC+Cipher+DS</option></select></div>' +
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">TAR (3 bytes)</label>' +
'<input value="' + escHtml(tar) + '" oninput="' + uf('tkTar') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800" maxlength="6"></div>';
if (tkMode === 'ca') {
html += '<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Access domain (hex)</label>' +
'<input value="' + escHtml(ad) + '" oninput="' + uf('tkAd') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800" maxlength="2"></div>';
} else {
html += '<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Max services</label>' +
'<input type="number" min="0" max="255" value="' + escHtml(services) + '" oninput="' + uf('tkServices') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800"></div>';
}
html += '</div></div>';
return html;
}
// ===== SIM/USIM hex builder =====
function chainSimBuildRowHex(chainId, idx, row) {
var f = row.fields;
var isSim = chainId === 'chain-sim';
var cla = isSim ? 0xA0 : 0x00;
if (row.cmd === 'select') {
var method = f.method || 'fid';
var silent = !isSim && f.silent;
var p2 = silent ? 0x0C : (isSim ? 0x00 : 0x04);
if (method === 'fid') {
var fid = (f.fid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (fid.length !== 4) return '';
var apdu = buildApdu(cla, 0xA4, 0x00, p2, 0x02, fid);
if (!isSim && !silent) apdu += '00';
return apdu;
}
if (method === 'path') {
var path = (f.path || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (path.length < 2 || path.length % 2 !== 0) return '';
var p1 = (!isSim && f.base === 'df') ? 0x09 : 0x08;
var apdu2 = buildApdu(cla, 0xA4, p1, p2, path.length / 2, path);
if (!isSim && !silent) apdu2 += '00';
return apdu2;
}
if (method === 'dfname') {
var aid = (f.dfname || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (aid.length < 2) return '';
var apdu3 = buildApdu(cla, 0xA4, 0x04, p2, aid.length / 2, aid);
if (!isSim && !silent) apdu3 += '00';
return apdu3;
}
if (method === 'chain') {
var tokens = (f.chain || '').split(',').map(function(t) { return t.trim(); }).filter(function(t) { return t.length > 0; });
var out = '';
for (var ti = 0; ti < tokens.length; ti++) {
var tok = tokens[ti];
var gr = /^c0(?::([0-9a-fA-F]{1,2}))?$/i.exec(tok);
if (gr) {
var leByte = parseInt(gr[1] || '00', 16);
out += buildApdu(cla, 0xC0, 0x00, 0x00, leByte, '');
continue;
}
var fid2 = tok.replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (fid2.length !== 4) continue;
var s = buildSelect(cla, 0x00, p2, fid2);
if (!isSim && !silent) s += '00';
out += s;
}
return out;
}
return '';
}
if (row.cmd === 'get-response') {
return '00C0000000';
}
var OPS2 = {
'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},
};
var op = OPS2[row.cmd];
if (!op) return '';
var p1, p2v, p3, data = null;
if (row.cmd === 'read-record') {
var rm = parseInt(f.recMode || '04', 16);
var rec = parseInt(f.record) || 1;
var le = parseInt(f.le || '00', 16);
p1 = (rm === 0x02 || rm === 0x03) ? 0x00 : rec;
p2v = rm; p3 = le;
} else if (row.cmd === 'read-binary') {
var off = parseInt((f.offset || '0000').replace(/[^0-9a-fA-F]/g, ''), 16) || 0;
var le2 = parseInt(f.le || '00', 16);
p1 = (off >> 8) & 0xFF; p2v = off & 0xFF; p3 = le2;
} else if (row.cmd === 'update-record') {
var rm2 = parseInt(f.recMode || '04', 16);
var rec2 = parseInt(f.record) || 1;
data = (f.data || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (!data) return '';
p1 = (rm2 === 0x02 || rm2 === 0x03) ? 0x00 : rec2;
p2v = rm2; p3 = data.length / 2;
} else if (row.cmd === 'update-binary') {
var off2 = parseInt((f.offset || '0000').replace(/[^0-9a-fA-F]/g, ''), 16) || 0;
data = (f.data || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (!data) return '';
p1 = (off2 >> 8) & 0xFF; p2v = off2 & 0xFF; p3 = data.length / 2;
} else if (row.cmd === 'erase-binary') {
var off3 = parseInt((f.offset || '0000').replace(/[^0-9a-fA-F]/g, ''), 16) || 0;
p1 = (off3 >> 8) & 0xFF; p2v = off3 & 0xFF; p3 = 0x00;
} else if (row.cmd === 'activate-file' || row.cmd === 'deactivate-file') {
var target = f.target || 'current';
if (target === 'current') { p1 = 0x00; p2v = 0x00; p3 = null; }
else {
var fileHex = (f.file || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (!fileHex || fileHex.length % 2 !== 0) return '';
p1 = target === 'fid' ? 0x00 : (target === 'mfpath' ? 0x08 : 0x09);
p2v = 0x00; p3 = fileHex.length / 2; data = fileHex;
}
} else if (row.cmd === 'verify' || row.cmd === 'disable' || row.cmd === 'enable') {
var pin = f.pin || '01';
var pinVal = f.pinVal || '';
if (!pinVal) return '';
var pinHex = Array.from(pinVal).map(function(c) { return c.charCodeAt(0).toString(16).padStart(2, '0'); }).join('').padEnd(16, 'F').slice(0, 16);
p1 = 0x00; p2v = parseInt(pin, 16); p3 = 0x08; data = pinHex;
} else if (row.cmd === 'change' || row.cmd === 'unblock') {
var pin2 = f.pin || '01';
var oldPin = f.pinOld || '';
var newPin = f.pinNew || '';
if (!oldPin || !newPin) return '';
var oldHex = Array.from(oldPin).map(function(c) { return c.charCodeAt(0).toString(16).padStart(2, '0'); }).join('').padEnd(16, 'F').slice(0, 16);
var newHex = Array.from(newPin).map(function(c) { return c.charCodeAt(0).toString(16).padStart(2, '0'); }).join('').padEnd(16, 'F').slice(0, 16);
p1 = 0x00; p2v = parseInt(pin2, 16); p3 = 0x10; data = oldHex + newHex;
} else {
return '';
}
return buildApdu(cla, op.ins, p1, p2v, p3, data);
}
// ===== RAM/GP hex builder =====
function chainRamBuildRowHex(idx, row) {
var f = row.fields;
var cmd = row.cmd;
function lv(data) { if (!data) return '00'; return berLenStr(data.length / 2) + data; }
function computePriv() {
var ph = f.priv || '00';
var b1 = parseInt(ph.substring(0, 2), 16) || 0;
var b2 = ph.length >= 4 ? parseInt(ph.substring(2, 4), 16) : 0;
var b3 = ph.length >= 6 ? parseInt(ph.substring(4, 6), 16) : 0;
if (b2 === 0 && b3 === 0) return b1.toString(16).padStart(2, '0').toUpperCase();
var r = b1.toString(16).padStart(2, '0').toUpperCase() + b2.toString(16).padStart(2, '0').toUpperCase();
if (b3) r += b3.toString(16).padStart(2, '0').toUpperCase();
return r;
}
function buildTkParams() {
if (!f.tkEnabled) return '';
var tkMode = f.tkMode || 'ea';
var priority = parseInt(f.tkPriority) || 0;
var timers = parseInt(f.tkTimers) || 0;
var textLen = parseInt(f.tkTextlen) || 0;
var menus = parseInt(f.tkMenus) || 0;
var firstPos = parseInt(f.tkFirstpos) || 0;
var firstId = (f.tkFirstid || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2) || '00';
var lastPos = parseInt(f.tkLastpos) || 0;
var lastId = (f.tkLastid || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2) || '00';
var channels = parseInt(f.tkChannels) || 0;
var msl = f.tkMsl || '00';
var tar = (f.tkTar || '').replace(/[^0-9a-fA-F]/g, '');
var ad = (f.tkAd || '').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2);
var services = parseInt(f.tkServices) || 0;
if (tar && tar.length % 6 !== 0) return null;
var menuPairs = '';
for (var 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';
}
var mslField = msl === '00' ? '00' : '0201' + msl;
var tkPayload = '';
if (tkMode === 'ca') {
tkPayload = (f.tkAd ? '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');
var 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');
var innerTlv = '80' + berLenStr(tkPayload.length / 2) + tkPayload;
return 'EA' + berLenStr(innerTlv.length / 2) + innerTlv;
}
}
var INSTALL_P1 = {
'install-load': 0x02, 'install-install': 0x0C,
'install-make-sel': 0x08, 'install-reg-update': 0x40,
'install-extradition': 0x10,
};
var aid = (f.aid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
var sdAid = (f.sdAid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (cmd === 'install-load') {
var loadFileAid = (f.loadFileAid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
var loadData = (f.loadData || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (!loadFileAid) return '';
var dataField = lv(loadFileAid) + lv(sdAid) + '00' + (loadData ? lv(loadData) : '00') + '00';
var dataLen = berLenStr(dataField.length / 2);
return buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dataLen, 16), dataField) + '00';
}
if (cmd === 'install-install') {
var elfAid = (f.elfAid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
var modAid = (f.modAid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (!aid) return '';
var privBytes = computePriv();
var installParams = 'C900';
var tkParams = buildTkParams();
if (tkParams === null) return '';
if (tkParams) installParams += tkParams;
var df2 = lv(elfAid) + lv(modAid) + lv(aid) + lv(privBytes) + lv(installParams) + '00';
var dl2 = berLenStr(df2.length / 2);
return buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dl2, 16), df2) + '00';
}
if (cmd === 'install-make-sel') {
if (!aid) return '';
var privBytes2 = computePriv();
var df3 = '00' + '00' + lv(aid) + lv(privBytes2) + '00' + '00';
var dl3 = berLenStr(df3.length / 2);
return buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dl3, 16), df3) + '00';
}
if (cmd === 'install-reg-update') {
if (!aid) return '';
var privBytes3 = computePriv();
var df4 = lv(sdAid) + '00' + lv(aid) + lv(privBytes3) + '00' + '00';
var dl4 = berLenStr(df4.length / 2);
return buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dl4, 16), df4) + '00';
}
if (cmd === 'install-extradition') {
if (!sdAid || sdAid.length < 10 || sdAid.length > 20) return '';
if (!aid) return '';
var df5 = lv(sdAid) + '00' + lv(aid) + '00' + '00' + '00';
var dl5 = berLenStr(df5.length / 2);
return buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dl5, 16), df5) + '00';
}
if (cmd === 'load') {
var ramData = (f.data || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
var blockNum = parseInt(f.block) || 0;
if (!ramData) return '';
var dl6 = berLenStr(ramData.length / 2);
return buildApdu(0x80, 0xE8, 0x80, blockNum, parseInt(dl6, 16), ramData) + '00';
}
if (cmd === 'delete') {
if (!aid) return '';
var delMode = f.delMode || '00';
var df7 = '4F' + berLenStr(aid.length / 2) + aid;
var dl7 = berLenStr(df7.length / 2);
return buildApdu(0x80, 0xE4, 0x00, parseInt(delMode, 16), parseInt(dl7, 16), df7) + '00';
}
if (cmd === 'get-status') {
var gsMode = f.gsMode || '80';
var gsP2 = f.gsP2 || '00';
return buildApdu(0x80, 0xF2, parseInt(gsMode, 16), parseInt(gsP2, 16), 0x02, '4F00') + '00';
}
if (cmd === 'get-data') {
var tagHex = (f.tag || '5F50').replace(/[^0-9a-fA-F]/g, '').padStart(4, '0').slice(0, 4);
var tag = parseInt(tagHex, 16);
if (tagHex === '2F00' || tagHex === '5031') {
return buildApdu(0x80, 0xCA, (tag >> 8) & 0xFF, tag & 0xFF, 0x02, '5C00') + '00';
}
return buildApdu(0x80, 0xCA, (tag >> 8) & 0xFF, tag & 0xFF, 0x00, null) + '00';
}
if (cmd === 'store-data') {
var ramData2 = (f.data || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
var enc = f.enc || '00';
var blockNum2 = parseInt(f.block) || 0;
if (!ramData2) return '';
var dl8 = berLenStr(ramData2.length / 2);
return buildApdu(0x80, 0xE2, parseInt(enc, 16), blockNum2, parseInt(dl8, 16), ramData2);
}
if (cmd === 'set-status') {
var ssMode = f.ssMode || '80';
var ssState = f.ssState || '00';
if (ssMode === '80') {
return buildApdu(0x80, 0xF0, 0x80, parseInt(ssState, 16), 0x00, null);
}
if (!aid) return '';
return buildApdu(0x80, 0xF0, parseInt(ssMode, 16), parseInt(ssState, 16), aid.length / 2, aid);
}
if (cmd === 'ext-auth') {
var ramData3 = (f.data || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
var authP1 = f.authP1 || '00';
if (!ramData3 || ramData3.length !== 32) return '';
return buildApdu(0x84, 0x82, parseInt(authP1, 16), 0x00, 0x10, ramData3);
}
if (cmd === 'int-auth') {
var ramData4 = (f.data || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (!ramData4) return '';
var dl9 = berLenStr(ramData4.length / 2);
return buildApdu(0x00, 0x88, 0x00, 0x00, parseInt(dl9, 16), ramData4) + '00';
}
if (cmd === 'get-response') {
var le = parseInt(f.le, 16) || 0;
return buildApdu(0x80, 0xC0, 0x00, 0x00, le, null);
}
return '';
}
// ===== Chain hex builder (with Le stripping) =====
function chainBuildRowHex(chainId, idx) {
chainInit(chainId);
var row = _chains[chainId].rows[idx];
if (!row) return '';
if (chainId === 'chain-sim' || chainId === 'chain-usim') {
return chainSimBuildRowHex(chainId, idx, row);
}
return chainRamBuildRowHex(idx, row);
}
function chainIsCase4(cmd, chainId) {
if (cmd === 'select') return chainId === 'chain-usim';
if (cmd === 'get-status' || cmd === 'get-data' || cmd === 'store-data' || cmd === 'int-auth') return true;
return false;
}
function chainBuildHex(chainId) {
chainInit(chainId);
var rows = _chains[chainId].rows;
if (!rows.length) return '';
var parts = [];
for (var i = 0; i < rows.length; i++) {
var rowHex = chainBuildRowHex(chainId, i);
if (i < rows.length - 1 && rows[i + 1].cmd === 'get-response') {
if (chainIsCase4(rows[i].cmd, chainId) && rowHex.length >= 2) {
rowHex = rowHex.slice(0, -2);
}
}
parts.push(rowHex);
}
return parts.join('');
}
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('scp80');
scp80SwitchSubtab('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 spInvalidate() {
document.getElementById('sp-result').value = '';
}
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();
spInvalidate();
}
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);
spInvalidate();
}
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);
spInvalidate();
}
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);
}
// pySim encode_cmd parity: the 2-byte CPL prefix is transmitted only when
// ciphering is applied; unciphered packets start at CHL (v1.9.5).
resultEl.value = bytesToHex(ciphering ? packet : packet.subarray(2));
}
// ===== 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',
// TS 51.011 §9.4 SIM-specific families
'9200': 'Command successful after internal update retry routine',
'9240': 'Memory problem',
'9400': 'No EF selected',
'9402': 'Out of range (invalid address)',
'9404': 'File ID not found / pattern not found',
'9408': 'File inconsistent with the command',
'9802': 'No CHV initialized',
'9804': 'Access condition not fulfilled / CHV verification failed, attempts left / authentication failed',
'9808': 'In contradiction with CHV status',
'9810': 'In contradiction with invalidation status',
'9840': 'CHV verification failed - no attempt left; CHV blocked; UNBLOCK blocked',
'9850': 'Increase cannot be performed, max value reached',
'9EXX': 'SIM data download error (SW2 = length)',
'9FXX': 'Response data available (SW2 = N bytes) - send GET RESPONSE',
'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;
const firstByte = parseInt(tag, 16);
// Multi-byte tags: bits 4-0 of first byte are 0x1F (ISO 7816)
if ((firstByte & 0x1F) === 0x1F) { 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;
// Constructed if bit 5 of first tag byte is set (ISO 7816)
const isConstructed = (firstByte & 0x20) !== 0;
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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 sendResultEl = document.getElementById('sp-send-result');
const porStatusEl = document.getElementById('sp-por-status');
sendResultEl.classList.remove('hidden', 'text-red-600', 'text-green-600');
sendResultEl.classList.add('text-gray-500');
sendResultEl.textContent = t('Sending OTA...');
porStatusEl.classList.add('hidden');
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 || '');
const okPor = por.response_status === 'por_ok';
porStatusEl.textContent = 'PoR: ' + por.response_status;
porStatusEl.classList.remove('hidden', okPor ? 'text-red-600' : 'text-green-600');
porStatusEl.classList.add(okPor ? 'text-green-600' : 'text-red-600');
if (okPor) {
// packet was accepted and executed - advance the counter so the
// same secured packet can never be sent twice (v1.9.6)
const cntrEl = document.getElementById('sp-cntr');
cntrEl.value = ((parseInt(cntrEl.value, 16) || 0) + 1)
.toString(16).toUpperCase().padStart(10, '0').slice(-10);
document.getElementById('sp-result').value = '';
// keep the selected card preset in sync with the new counter (v1.9.8)
const selIdx = parseInt(document.getElementById('sp-card-sel').value, 10);
if (!isNaN(selIdx) && cards[selIdx]) {
cards[selIdx].cntr = cntrEl.value;
cardsSave();
cardsRender();
}
}
}
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', 'text-gray-500');
sendResultEl.classList.add('text-green-600');
} 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', 'text-gray-500');
sendResultEl.classList.add('text-red-600');
}
} catch (e) {
sendResultEl.textContent = 'Error: ' + e.message;
sendResultEl.classList.remove('hidden', 'text-green-600', 'text-gray-500');
sendResultEl.classList.add('text-red-600');
}
}
// ===== SCP80/RAM pill =====
// Atomic GlobalPlatform Remote Application Management over SCP80.
// Simple ops (GET DATA / GET STATUS / DELETE) build the GP APDU locally and
// reuse /api/send-ota. Install Package sends the .cap hex to /api/ram-install
// which orchestrates INSTALL[for load] -> LOAD x N -> INSTALL[for install].
function ramRender() {
// populate the card preset selector from the in-memory cards[] array
const sel = document.getElementById('ram-card-sel');
if (!sel) return;
sel.innerHTML = '<option value="" data-l10n="— Select card —">— Select card —</option>';
cards.forEach((c, i) => {
const opt = document.createElement('option');
opt.value = i;
opt.textContent = c.name || ('Card ' + i);
sel.appendChild(opt);
});
ramOpChanged();
}
function ramApplyCard(idx) {
// copy the selected card preset into the SP form fields so that
// getRamSpParams() picks up the right SPI/KIc/KID/TAR/CNTR/keys
cardsApply(idx);
}
function ramOpChanged() {
const op = document.getElementById('ram-op').value;
document.getElementById('ram-install-params').classList.toggle('hidden', op !== 'install-cap');
if (op !== 'explore') {
document.getElementById('ram-explorer').classList.add('hidden');
document.getElementById('ram-explorer').innerHTML = '';
}
}
function ramShowProgress(text) {
const el = document.getElementById('ram-progress');
el.classList.remove('hidden');
document.getElementById('ram-progress-text').textContent = text;
}
function ramHideProgress() {
document.getElementById('ram-progress').classList.add('hidden');
}
function ramClearResults() {
document.getElementById('ram-result').classList.add('hidden');
document.getElementById('ram-explorer').classList.add('hidden');
document.getElementById('ram-explorer').innerHTML = '';
document.getElementById('ram-steps').classList.add('hidden');
document.getElementById('ram-steps').textContent = '';
}
// Read a File as a hex string via FileReader (client-side, no upload)
function ramReadFileHex(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const bytes = new Uint8Array(reader.result);
let hex = '';
for (let i = 0; i < bytes.length; i++) hex += bytes[i].toString(16).padStart(2, '0');
resolve(hex.toUpperCase());
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsArrayBuffer(file);
});
}
// Collect SP params from the SP form (populated by ramApplyCard -> cardsApply)
function getRamSpParams() {
return {
spi1: document.getElementById('sp-spi1').value,
spi2: document.getElementById('sp-spi2').value,
kic: document.getElementById('sp-kic-hex').value,
kid: document.getElementById('sp-kid-hex').value,
tar: (document.getElementById('sp-tar').value || '000000').replace(/[^0-9a-fA-F]/g, ''),
cntr: (document.getElementById('sp-cntr').value || '0000000001').replace(/[^0-9a-fA-F]/g, ''),
kicKey: (document.getElementById('sp-kic-key').value || '').replace(/[^0-9a-fA-F]/g, ''),
kidKey: (document.getElementById('sp-kid-key').value || '').replace(/[^0-9a-fA-F]/g, ''),
};
}
function ramIncrementCntr(cntr) {
let v = (parseInt(cntr, 16) || 0) + 1;
return v.toString(16).toUpperCase().padStart(10, '0').slice(-10);
}
function ramSaveCntr(cntr) {
const el = document.getElementById('sp-cntr');
el.value = cntr;
const selIdx = parseInt(document.getElementById('ram-card-sel').value, 10);
if (!isNaN(selIdx) && cards[selIdx]) {
cards[selIdx].cntr = cntr;
cardsSave();
cardsRender();
ramRender();
}
}
// Send a single GP APDU wrapped in SCP80 via /api/send-ota
async function ramSendOta(apduHex, sp) {
// RAM operations: send raw GP command + SCP80 params; server handles SCP80 wrapping
// Caller controls SPI2: 0x01 = PoR via ENVELOPE response, 0x21 = PoR via SMS-SUBMIT
const body = Object.assign({}, sp, { apdu: apduHex, sp: '', includeCpi: true });
const res = await fetch('/api/send-ota', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return await res.json();
}
// ===== TLV parsers for GET STATUS responses (P2=02 format) =====
// P2=02 wraps each entry in an 'E3' GP Registry Data template containing TLV tags.
// Fallback: if the card only supports P2=00 (raw format), raw parsers are used.
function _parseE3Entry(hex) {
const children = parseTLV(hex || '');
const r = {};
for (const c of children) {
if (c.tag === '4F') r.aid = c.value.toUpperCase();
else if (c.tag === '9F70') r.lifecycle = c.value.toUpperCase();
else if (c.tag === 'C5') r.privileges = c.value.toUpperCase();
else if (c.tag === 'CE') r.version = c.value.toUpperCase();
else if (c.tag === 'CC') r.sdAid = c.value.toUpperCase();
else if (c.tag === 'C4') r.elfAid = c.value.toUpperCase();
else if (c.tag === 'CF') r.implicitSel = c.value.toUpperCase();
else if (c.tag === '84') r.moduleAids = ramParseModuleAids(c.value);
}
return r;
}
// Raw format (P2=00): ISD uses pure AID length, Apps use combined length (AID+lifecycle).
// Heuristic: length > 8 means combined (AID = length-1 bytes, lifecycle inside length).
function _parseRawAppEntry(hex, i) {
const rawLen = parseInt(hex.substr(i, 2), 16);
if (rawLen < 1 || i + 2 + rawLen * 2 + 2 > hex.length) return null;
const aidLen = rawLen > 8 ? rawLen - 1 : rawLen;
const aid = hex.substr(i + 2, aidLen * 2).toUpperCase();
let j = i + 2 + rawLen * 2;
const lifecycle = hex.substr(j, 2).toUpperCase(); j += 2;
const privileges = hex.substr(j, 2).toUpperCase(); j += 2;
return { aid, lifecycle, privileges, next: j };
}
// Raw format (P2=00): ELF header then trailing bytes with module entries.
// P1=20: <ELF_header> <00> <module_entries separated by 00>
// P1=10: <ELF_header> <version(2B)> <SD_AID> <module_entries...>
// In both, a module entry starts with 0x10 (len=16) followed by 15-byte AID + lifecycle.
// Scan for 0x10 markers, advance past each full entry to avoid AID-internal false positives.
function _parseRawElfEntry(hex, i) {
const aidLen = parseInt(hex.substr(i, 2), 16);
if (aidLen < 1 || i + 2 + aidLen * 2 + 2 > hex.length) return null;
const aid = hex.substr(i + 2, aidLen * 2).toUpperCase();
let j = i + 2 + aidLen * 2;
const lifecycle = hex.substr(j, 2).toUpperCase(); j += 2;
// Scan: when 0x10 found, validate 15-byte AID follows, then advance past full entry
const moduleAids = [];
const seen = new Set();
while (j + 32 <= hex.length) {
const tag = parseInt(hex.substr(j, 2), 16);
if (tag === 0x10) {
const modAid = hex.substr(j + 2, 30).toUpperCase();
if (!seen.has(modAid)) { seen.add(modAid); moduleAids.push(modAid); }
j += 32; // skip marker(1) + AID(15), continue past lifecycle+appCount
} else {
j += 2;
}
}
return { aid, lifecycle, moduleAids: moduleAids.length ? moduleAids : undefined, next: j };
}
function ramParseAppStatus(hex) {
const s = (hex || '').toUpperCase();
if (!s) return [];
// TLV format (P2=02): E3 templates with structured tags
if (s.length >= 4 && s.substr(0, 2) === 'E3') {
const tlvs = parseTLV(s);
return tlvs.filter(t => t.tag === 'E3').map(t => {
const r = _parseE3Entry(t.value);
r.type = 'app';
return r;
}).filter(r => r.aid);
}
// Raw format (P2=00 fallback): consecutive <aid_len><AID><lifecycle><privileges>
const out = [];
let i = 0;
while (i + 6 <= s.length) {
const r = _parseRawAppEntry(s, i);
if (!r) break;
out.push({ type: 'app', ...r });
i = r.next;
}
return out;
}
function ramParseElfStatus(hex) {
const s = (hex || '').toUpperCase();
if (!s) return [];
// TLV format (P2=02): E3 templates with structured tags
if (s.length >= 4 && s.substr(0, 2) === 'E3') {
const tlvs = parseTLV(s);
return tlvs.filter(t => t.tag === 'E3').map(t => {
const r = _parseE3Entry(t.value);
r.type = 'elf';
return r;
}).filter(r => r.aid);
}
// Raw format (P2=00 fallback): consecutive <aid_len><AID><lifecycle>
const out = [];
let i = 0;
while (i + 6 <= s.length) {
const r = _parseRawElfEntry(s, i);
if (!r) break;
out.push({ type: 'elf', ...r });
i = r.next;
}
return out;
}
// Parse the '84' module-AID list (concatenated 4F<len><aid> TLVs) into an array
function ramParseModuleAids(hex) {
const out = [];
let j = 0;
const s = (hex || '').toUpperCase();
while (j + 4 <= s.length) {
const t = s.substr(j, 2);
const l = parseInt(s.substr(j + 2, 2), 16);
const v = s.substr(j + 4, l * 2);
j += 4 + l * 2;
if (t === '4F') out.push(v);
}
return out;
}
// GET DATA FF21 response: 81 app count, 82 free NV (3B), 83 free volatile (2B)
function ramParseGetMemory(hex) {
const s = (hex || '').toUpperCase();
if (!s.startsWith('FF21')) return null;
let i = 6; // Skip FF21 (4 bytes) + length byte (1 byte) = start at first tag
const out = {};
while (i + 4 <= s.length) {
const t = s.substr(i, 2);
const l = parseInt(s.substr(i + 2, 2), 16);
const v = s.substr(i + 4, l * 2);
i += 4 + l * 2;
if (t === '81') out.appCount = parseInt(v, 16);
else if (t === '82') out.freeNV = parseInt(v, 16);
else if (t === '83') out.freeV = parseInt(v, 16);
}
return out;
}
// ===== Lifecycle / privilege formatting =====
const RAM_LIFECYCLE = {
'01': 'OP_READY', '07': 'SELECTABLE', '0F': 'PERSONALIZED',
'03': 'INSTALLED', '1F': 'SD_PERSONALIZED', '7F': 'LOCKED',
'FF': 'TERMINATED',
};
function ramFmtLifecycle(hex) {
return RAM_LIFECYCLE[hex] || ('0x' + hex);
}
// Decode GP Card Spec privileges TLV (tag C5) into human-readable strings
function ramFmtPrivileges(hex) {
const p = hex || '';
if (!p) return '(none)';
const bytes = p.match(/.{2}/g) || [];
const privs = [];
const b1 = parseInt(bytes[0] || '00', 16);
if (b1 & 0x01) privs.push('Card Lock');
if (b1 & 0x02) privs.push('Card Terminate');
if (b1 & 0x04) privs.push('Card Reset');
if (b1 & 0x08) privs.push('Cum Deletion Ctr');
if (b1 & 0x10) privs.push('GSM Card Binding');
if (b1 & 0x20) privs.push('Default Selected');
if (b1 & 0x40) privs.push('Global PIN');
const b2 = parseInt(bytes[1] || '00', 16);
if (b2 & 0x01) privs.push('Mandated DAP');
if (b2 & 0x02) privs.push('Security Domain');
if (b2 & 0x04) privs.push('DAP Verification');
if (b2 & 0x08) privs.push('Delegated Mgmt');
if (b2 & 0x10) privs.push('RFM');
if (b2 & 0x20) privs.push('CFM');
const b3 = parseInt(bytes[2] || '00', 16);
if (b3 & 0x01) privs.push('Receipt Gen');
if (b3 & 0x02) privs.push('Ciphered Load');
if (b3 & 0x04) privs.push('Delegated Perso');
if (b3 & 0x08) privs.push('Trusted Path');
if (b3 & 0x10) privs.push('Authorized Mgmt');
return privs.length ? privs.join(', ') : '(none)';
}
// Merge ELF-module entries (P1=10, carry module AIDs) into ELF entries (P1=20).
// With TLV format (P2=02), module AIDs come from tag '84' inside E3 templates.
// With raw format (P2=00), module AIDs are not available in the response.
function ramMergeElfData(elfs, modules) {
const byAid = {};
elfs.forEach(e => { if (e.aid) byAid[e.aid] = e; });
modules.forEach(m => {
if (m.aid && byAid[m.aid]) {
if (m.moduleAids && m.moduleAids.length) byAid[m.aid].moduleAids = m.moduleAids;
} else if (m.aid) {
elfs.push(m);
}
});
return elfs;
}
// Render the full explorer result as structured HTML
function ramRenderExploreHtml(mem, isd, apps, elfs) {
let html = '';
if (mem && (mem.appCount != null || mem.freeNV != null || mem.freeV != null)) {
html += '<div class="mb-4 p-3 bg-gray-50 dark:bg-slate-800 rounded">';
html += '<div class="font-semibold text-sm mb-1" data-l10n="Memory (GET DATA FF21)">Memory (GET DATA FF21)</div>';
html += '<div>Applications: ' + (mem.appCount != null ? mem.appCount : '?') + '</div>';
html += '<div>Free NV: ' + (mem.freeNV != null ? mem.freeNV + ' B' : '?') + '</div>';
html += '<div>Free Volatile: ' + (mem.freeV != null ? mem.freeV + ' B' : '?') + '</div>';
html += '</div>';
}
if (isd && isd.length) {
html += '<div class="mb-4">';
html += '<div class="font-semibold text-sm mb-1" data-l10n="ISD (Issuer Security Domain)">ISD (Issuer Security Domain)</div>';
isd.forEach(o => {
html += '<div class="mb-1 pl-2 border-l-2 border-blue-400">';
html += '<div>AID: ' + (o.aid || '?') + '</div>';
html += '<div>Lifecycle: ' + ramFmtLifecycle(o.lifecycle || '') + '</div>';
if (o.privileges) html += '<div>Privileges: ' + ramFmtPrivileges(o.privileges) + ' (' + o.privileges + ')</div>';
if (o.sdAid) html += '<div>SD AID: ' + o.sdAid + '</div>';
html += '</div>';
});
html += '</div>';
}
if (apps && apps.length) {
html += '<div class="mb-4">';
html += '<div class="font-semibold text-sm mb-1" data-l10n="Applications / Applet Instances">Applications / Applet Instances</div>';
apps.forEach(o => {
html += '<div class="mb-1 pl-2 border-l-2 border-green-400">';
html += '<div>Application / Instance AID: ' + (o.aid || '?');
if (o.aid) {
html += ' <button onclick="ramDeleteFromExplorer(\'' + o.aid + '\', false)" class="ml-2 px-2 py-0.5 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400" data-l10n="Delete">Delete</button>';
}
html += '</div>';
html += '<div>Lifecycle: ' + ramFmtLifecycle(o.lifecycle || '') + '</div>';
if (o.privileges) html += '<div>Privileges: ' + ramFmtPrivileges(o.privileges) + ' (' + o.privileges + ')</div>';
if (o.implicitSel) html += '<div>Implicit sel: ' + o.implicitSel + '</div>';
if (o.elfAid) html += '<div>Load File AID / Package AID: ' + o.elfAid + '</div>';
if (o.sdAid) html += '<div>SD AID: ' + o.sdAid + '</div>';
html += '</div>';
});
html += '</div>';
}
if (elfs && elfs.length) {
html += '<div class="mb-4">';
html += '<div class="font-semibold text-sm mb-1" data-l10n="Executable Load Files (ELFs) / Packages">Executable Load Files (ELFs) / Packages</div>';
elfs.forEach(o => {
html += '<div class="mb-1 pl-2 border-l-2 border-purple-400">';
html += '<div>Load File AID / Package AID: ' + (o.aid || '?');
if (o.aid) {
html += ' <button onclick="ramDeleteFromExplorer(\'' + o.aid + '\', false)" class="ml-2 px-2 py-0.5 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400" data-l10n="Delete">Delete</button>';
html += ' <button onclick="ramDeleteFromExplorer(\'' + o.aid + '\', true)" class="ml-1 px-2 py-0.5 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400" data-l10n="Delete All">Delete All</button>';
}
html += '</div>';
html += '<div>Lifecycle: ' + ramFmtLifecycle(o.lifecycle || '') + '</div>';
if (o.version) html += '<div>Version: ' + o.version + '</div>';
if (o.moduleAids && o.moduleAids.length) {
html += '<div>Executable Module AIDs / Applet Class AIDs:</div>';
o.moduleAids.forEach(m => { html += '<div class="pl-4">- ' + m + '</div>'; });
}
if (o.sdAid) html += '<div>SD AID: ' + o.sdAid + '</div>';
html += '</div>';
});
html += '</div>';
}
return html;
}
// ===== Operation handlers =====
async function ramExplore(sp) {
const resultEl = document.getElementById('ram-result');
const explorerEl = document.getElementById('ram-explorer');
const stepsEl = document.getElementById('ram-steps');
let cntr = sp.cntr;
const errors = [];
const mem = { appCount: null, freeNV: null, freeV: null };
const isd = [], apps = [], elfs = [], modules = [];
async function paginate(p1, collector, parser, label) {
// Chain GET STATUS + GET RESPONSE into a single SCP80 payload.
// The card's SCP80 layer executes both: GET STATUS returns 61XX,
// then GET RESPONSE fetches the data — the PoR captures the final
// result (9000 + response data) without the frontend handling 61XX.
// ELF queries (P1=20/10) use SPI2=0x21 (PoR via SMS-SUBMIT) because
// ELF data won't fit in the ENVELOPE response.
const isElf = (p1 === '20' || p1 === '10');
const spi2 = isElf ? '21' : '01';
// Try TLV format (P2=02) first for structured data; fall back to raw
// (P2=00) if the card doesn't support TLV GET STATUS.
for (const p2Init of ['02', '00']) {
let p2 = p2Init;
let guard = 0;
let tlvFailed = false;
while (guard++ < 32) {
// P2=02: no data field (card returns all tags). P2=00: Lc=02 TagList=4F00 (ignored by card).
const dataField = p2 === '02' ? '' : '024F0000';
const apdu = '80F2' + p1 + p2 + dataField + 'C0000000';
ramShowProgress(label + ' P1=' + p1 + ' P2=' + p2 + '...');
const res = await ramSendOta(apdu, Object.assign({}, sp, { cntr, spi2 }));
cntr = ramIncrementCntr(cntr);
if (!res.success || !res.por || res.por.response_status !== 'por_ok') {
const errorMsg = res.por ? res.por.response_status : (res.error || 'no data');
errors.push(label + ': ' + errorMsg);
tlvFailed = true;
break;
}
const data = res.por.decoded ? res.por.decoded.last_response_data : '';
const sw = (res.por.decoded ? res.por.decoded.last_status_word : '').toUpperCase();
// If first attempt with P2=02 gets an unsupported error, retry with P2=00.
if (guard === 1 && p2Init === '02' && (sw === '6A86' || sw === '6A88')) {
tlvFailed = true;
break;
}
// Defensive: 61XX means more data available (shouldn't happen with
// chained GET RESPONSE, but handle it if the card responds this way).
if (sw && sw.startsWith('61')) {
if (data) {
const parsed61 = parser(data);
if (parsed61.length) collector.push(...parsed61);
}
p2 = '01';
continue;
}
if (sw === '6F00') { tlvFailed = true; break; }
if (!data) {
if (sw !== '9000') {
errors.push(label + ': (no data) — SW ' + sw);
tlvFailed = true;
}
break;
}
const parsed = parser(data);
if (!parsed.length) break;
collector.push(...parsed);
if (sw === '9000') break;
p2 = '01';
}
if (!tlvFailed) break;
}
}
ramShowProgress('GET DATA FF21 (memory)...');
try {
const memRes = await ramSendOta('80CAFF2100', Object.assign({}, sp, { spi2: '01' }));
cntr = ramIncrementCntr(cntr);
if (memRes.success && memRes.por && memRes.por.response_status === 'por_ok') {
const data = memRes.por.decoded ? memRes.por.decoded.last_response_data : '';
if (!data) {
errors.push('Memory: (no data)');
} else {
const m = ramParseGetMemory(data);
if (m) Object.assign(mem, m);
}
} else {
const errorMsg = memRes.por ? memRes.por.response_status : (memRes.error || 'no data');
errors.push('Memory: ' + errorMsg);
}
} catch (e) {
errors.push('Memory: ' + e.message);
}
// 2-5. GET STATUS for ISD / Apps / ELFs / ELF modules
// P1 per GP Card Spec v2.3.1 table 11-33:
// 80=ISD, 40=Applications, 20=Executable Load Files (ELFs), 10=ELF+modules
await paginate('80', isd, ramParseAppStatus, 'ISD');
await paginate('40', apps, ramParseAppStatus, 'Apps');
await paginate('20', elfs, ramParseElfStatus, 'ELFs');
await paginate('10', modules, ramParseElfStatus, 'ELF Modules');
ramMergeElfData(elfs, modules);
ramSaveCntr(cntr);
ramHideProgress();
if (errors.length) {
resultEl.textContent = 'Partial — ' + errors.join('; ');
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
stepsEl.classList.remove('hidden');
stepsEl.textContent = errors.join('\n');
} else {
resultEl.textContent = 'OK — ' + isd.length + ' ISD, ' + apps.length + ' apps, ' + elfs.length + ' ELFs' + (mem.freeNV ? ', ' + mem.freeNV + ' free NV' : '');
resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600');
}
const html = ramRenderExploreHtml(mem, isd, apps, elfs);
explorerEl.innerHTML = html || '(no data)';
explorerEl.classList.remove('hidden');
}
async function ramDeleteFromExplorer(aid, withCascade) {
const sp = getRamSpParams();
if (!sp.kicKey || !sp.kidKey) {
alert('Select a card preset with keys first (RAM subtab → Card preset)');
return;
}
const label = withCascade ? 'DELETE (cascade)' : 'DELETE';
if (!confirm(label + ' — AID: ' + aid + '?')) return;
const p2 = withCascade ? '80' : '00';
const aidLen = (aid.length / 2).toString(16).padStart(2, '0');
const apdu = '80E400' + p2 + _ber_len(2 + aid.length / 2) + '4F' + aidLen + aid;
ramShowProgress(label + ' ' + aid + '...');
const res = await ramSendOta(apdu, sp);
ramHideProgress();
const resultEl = document.getElementById('ram-result');
const stepsEl = document.getElementById('ram-steps');
if (!res.success || !res.por || res.por.response_status !== 'por_ok') {
resultEl.textContent = 'Failed: ' + (res.por ? res.por.response_status : res.error || res.sw);
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
return;
}
ramSaveCntr(ramIncrementCntr(sp.cntr));
const sw = res.por.decoded ? res.por.decoded.last_status_word : '';
stepsEl.classList.remove('hidden');
stepsEl.textContent = label + ' ' + aid + ' -> ' + sw;
resultEl.textContent = 'OK';
resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600');
await ramExplore(sp);
}
async function ramInstallCap(sp) {
const fileInput = document.getElementById('ram-cap-file');
const file = fileInput.files[0];
if (!file) { alert('Select a .cap file'); return; }
if (file.size > 48 * 1024) { alert('CAP file exceeds 48 kB limit'); return; }
ramShowProgress('Reading CAP file...');
const capHex = await ramReadFileHex(file);
ramShowProgress('Sending to server for install...');
const body = {
cap_hex: capHex,
sd_aid: (document.getElementById('ram-sd-aid').value || '').replace(/[^0-9a-fA-F]/g, ''),
privileges: (document.getElementById('rc-priv').value || '00').replace(/[^0-9a-fA-F]/g, ''),
install_params: (document.getElementById('ram-install-params-hex').value || '').replace(/[^0-9a-fA-F]/g, ''),
stk_params: (document.getElementById('ram-stk-params').value || '').replace(/[^0-9a-fA-F]/g, ''),
make_selectable: document.getElementById('ram-make-sel').checked,
spi1: sp.spi1, spi2: '01', kic: sp.kic, kid: sp.kid,
tar: sp.tar, cntr: sp.cntr, kicKey: sp.kicKey, kidKey: sp.kidKey,
};
const res = await fetch('/api/ram-install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
ramHideProgress();
const resultEl = document.getElementById('ram-result');
const stepsEl = document.getElementById('ram-steps');
stepsEl.classList.remove('hidden');
let txt = '';
(data.steps || []).forEach((s, idx) => {
const mark = s.por_status === 'por_ok' ? '✅' : '❌';
txt += mark + ' Step ' + (idx + 1) + ': ' + s.name + ' — ' + s.por_status + ' (SW ' + s.sw + ')\n';
});
stepsEl.textContent = txt;
if (data.success) {
ramSaveCntr(data.final_cntr);
resultEl.textContent = 'Install OK — load_file_aid=' + data.load_file_aid + ' module_aid=' + data.module_aid;
resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600');
} else {
resultEl.textContent = 'Install FAILED at step: ' + data.failed_step + (data.error ? ' (' + data.error + ')' : '');
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
}
}
async function ramExecute() {
ramClearResults();
const op = document.getElementById('ram-op').value;
const sp = getRamSpParams();
if (!sp.kicKey || !sp.kidKey) {
alert('Select a card preset with keys first (RAM subtab → Card preset)');
return;
}
try {
if (op === 'explore') await ramExplore(sp);
else if (op === 'install-cap') await ramInstallCap(sp);
} catch (e) {
const resultEl = document.getElementById('ram-result');
resultEl.textContent = 'Error: ' + e.message;
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
ramHideProgress();
}
}
// ===== STK Menu Browser =====
let stkMenuStack = [];
async function stkCheckMenu() {
try {
const data = await pysimFetch('/api/menu');
const btn = document.getElementById('stk-menu-btn');
const none = document.getElementById('stk-menu-none');
if (data && data.title && data.items && data.items.length) {
document.getElementById('stk-menu-title').textContent = data.title;
btn.style.display = '';
none.style.display = 'none';
} else {
btn.style.display = 'none';
none.style.display = '';
}
} catch (e) {
console.error('stkCheckMenu error:', e);
}
}
function stkSetPillsDisabled(disabled) {
document.querySelectorAll('.pysim-subtab').forEach(b => {
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();
cardsRender();
}
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;
const kicByte = parseInt(c.kic, 16);
document.getElementById('sp-kic-idx').value = ((kicByte >> 4) & 0x0F).toString(16).toUpperCase();
document.getElementById('sp-kic-alg').value = (kicByte & 0x0F).toString(16).padStart(2, '0');
updateSpKic();
const kidByte = parseInt(c.kid, 16);
document.getElementById('sp-kid-idx').value = ((kidByte >> 4) & 0x0F).toString(16).toUpperCase();
document.getElementById('sp-kid-alg').value = (kidByte & 0x0F).toString(16).padStart(2, '0');
updateSpKid();
document.getElementById('sp-tar').value = c.tar || 'B00001';
document.getElementById('sp-cntr').value = c.cntr;
document.getElementById('sp-kic-key').value = c.kicKey;
document.getElementById('sp-kid-key').value = c.kidKey;
spInvalidate();
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', 'profiler'].forEach(s => {
document.getElementById('pysim-sub-' + s).classList.toggle('hidden', s !== name);
});
if (name === 'custom') pysimCustomRender();
if (name === 'proactive') { stkCheckMenu(); pysimEventsRender(); pysimProactiveLogRender(); pysimPliRender(); pysimPollStatusInit(); }
if (name === 'profiler') profilerSetView('list');
pysimHelpAnchor = {cmd:'pysim-cmdline', apdu:'raw-apdu', files:'file-manager', custom:'custom-files', proactive:'proactive-uicc', profiler:'card-reader'}[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 = '&nbsp;'.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: '0255', 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();
// ===== Profiler =====
let profiles = [];
let profilerView = 'list';
let profilerEditId = null;
let profilerDraft = null;
let profilerResults = null;
// Files whose contents are ignored (contents check omitted) when scanning.
// Keyed by FID; display name is the pySim name.
const PROFILER_IGNORE_FILES = [
{ fid: '6F7E', name: 'EF.LOCI' },
{ fid: '6F73', name: 'EF.PSLOCI' },
{ fid: '6FE3', name: 'EF.EPSLOCI' },
{ fid: '4F01', name: 'EF.5GS3GPPLOCI' },
{ fid: '6F08', name: 'EF.Keys' },
{ fid: '6F09', name: 'EF.KeysPS' },
{ fid: '6F3C', name: 'EF.SMS' },
{ fid: '6F20', name: 'EF.Kc' },
{ fid: '4F52', name: 'EF.KcGPRS' },
{ fid: '6F53', name: 'EF.LOCIGPRS' },
{ fid: '6F48', name: 'EF.CBMID' },
{ fid: '6F43', name: 'EF.SMSS' },
];
// Files where contents are captured as a mask matching only the first 4 bytes
// (the rest are '?' wildcards). Keyed by FID; display name is the pySim name.
const PROFILER_MASK_PREFIX4_FIDS = {
'6F07': 'EF.IMSI',
'2FE2': 'EF.ICCID',
};
function profilerLoad() {
try {
const d = localStorage.getItem('otaman_profiles');
profiles = d ? JSON.parse(d) : [];
} catch (e) { profiles = []; }
}
function profilerSave() {
localStorage.setItem('otaman_profiles', JSON.stringify(profiles));
}
function profilerNewId() {
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
}
function profilerNormHex(s) {
return (s || '').toUpperCase().replace(/[^0-9A-F?]/g, '');
}
// Match an expected hex (may contain '?' nibble wildcards) against actual hex.
function profilerMatch(mode, expected, actual) {
const e = profilerNormHex(expected);
const a = profilerNormHex(actual);
if (!e) return false;
if (mode === 'mask') {
if (e.indexOf('?') < 0) {
return a.indexOf(e) === 0;
}
if (e.length !== a.length) return false;
for (let i = 0; i < e.length; i++) {
if (e[i] !== '?' && e[i] !== a[i]) return false;
}
return true;
}
return e === a;
}
// Like profilerMatch, but compares only the overlapping (shorter) portion.
function profilerMatchMin(mode, expected, actual) {
const e = profilerNormHex(expected);
const a = profilerNormHex(actual);
const n = Math.min(e.length, a.length);
return profilerMatch(mode, e.slice(0, n), a.slice(0, n));
}
// Keep the first 4 bytes (8 hex chars) exact and mask the rest with '?'.
function profilerMaskPrefix4(hex) {
const h = profilerNormHex(hex);
if (h.length <= 8) return h;
return h.slice(0, 8) + '?'.repeat(h.length - 8);
}
// Which size/record fields apply to a given file type.
function profilerFileFields(ft) {
if (ft === 'linear_fixed' || ft === 'cyclic') return { size: false, records: true };
if (ft === 'df') return { size: false, records: false };
if (ft === 'transparent' || ft === 'ber_tlv') return { size: true, records: false };
return { size: true, records: true };
}
// Content representation ('record' vs 'transparent') for a given file type.
function profilerContentKindForFileType(ft) {
return (ft === 'linear_fixed' || ft === 'cyclic') ? 'record' : 'transparent';
}
// A fresh record content object seeded with one empty record row.
function profilerEmptyRecordContent(mode) {
return { mode: mode, kind: 'record', records: [{ num: 1, data: '' }] };
}
function profilerValidateProfile(obj) {
if (!obj || typeof obj !== 'object') return 'Not an object';
if (!obj.name) return 'Missing name';
if (!Array.isArray(obj.rules)) return 'Missing rules array';
for (const r of obj.rules) {
if (!r || typeof r !== 'object') return 'Invalid rule';
if (r.type !== 'file') return 'Unsupported rule type: ' + r.type;
if (!r.path) return 'Rule missing path';
}
return null;
}
function profilerRenderList() {
const el = document.getElementById('profiler-list-body');
if (!profiles.length) {
el.innerHTML = '<span class="text-gray-400">' + t('No profiles defined.') + '</span>';
return;
}
let html = '';
for (let i = 0; i < profiles.length; i++) {
const p = profiles[i];
html += '<div class="flex items-center gap-2 py-1.5 border-b border-gray-200 dark:border-slate-700">';
html += '<b>' + esc(p.name) + '</b>';
html += '<span class="text-xs text-gray-400 dark:text-slate-500">' + esc(new Date(p.created).toLocaleString()) + '</span>';
html += '<span class="text-xs text-gray-400 dark:text-slate-500">(' + p.rules.length + ' ' + esc(t('rules')) + ')</span>';
html += '<span class="ml-auto flex gap-1">';
html += '<button onclick="profilerEdit(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700">' + esc(t('Edit')) + '</button>';
html += '<button onclick="profilerCheck(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700">' + esc(t('Check')) + '</button>';
html += '<button onclick="profilerExport(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-gray-600 text-white hover:bg-gray-700">' + esc(t('Export')) + '</button>';
html += '<button onclick="profilerDelete(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-red-600 text-white hover:bg-red-700">' + esc(t('Delete')) + '</button>';
html += '</span></div>';
}
el.innerHTML = html;
}
function profilerSetView(view) {
profilerView = view;
document.getElementById('profiler-list').classList.toggle('hidden', view !== 'list');
document.getElementById('profiler-editor').classList.toggle('hidden', view !== 'editor');
document.getElementById('profiler-results').classList.toggle('hidden', view !== 'results');
if (view === 'list') profilerRenderList();
}
function profilerNew() {
const name = prompt(t('Profile name') + ':', '');
if (!name) return;
profiles.push({ id: profilerNewId(), name: name, created: new Date().toISOString(), rules: [] });
profilerSave();
profilerEdit(profiles.length - 1);
}
function profilerFromCard() {
document.getElementById('profiler-scan-name').value = '';
const ignore = document.getElementById('profiler-scan-ignore');
let html = '';
for (const f of PROFILER_IGNORE_FILES) {
html += '<label class="flex items-center gap-2 text-sm">' +
'<input type="checkbox" data-ignore-fid="' + f.fid + '" checked>' +
'<span>' + esc(t('Ignore contents of')) + ' ' + esc(f.name) + '</span></label>';
}
ignore.innerHTML = html;
document.getElementById('profiler-scan-error').classList.add('hidden');
document.getElementById('profiler-scan-modal').classList.remove('hidden');
}
function profilerScanCancel() {
document.getElementById('profiler-scan-modal').classList.add('hidden');
}
async function profilerScanStart() {
const name = document.getElementById('profiler-scan-name').value.trim();
const errEl = document.getElementById('profiler-scan-error');
if (!name) {
errEl.textContent = t('Please enter a profile name');
errEl.classList.remove('hidden');
return;
}
const ignoreFids = new Set();
document.querySelectorAll('#profiler-scan-ignore input[data-ignore-fid]').forEach(cb => {
if (cb.checked) ignoreFids.add(cb.getAttribute('data-ignore-fid').toUpperCase());
});
const btn = document.getElementById('profiler-scan-btn');
btn.disabled = true;
btn.textContent = t('Scanning...');
try {
const rules = await profilerScanCard(ignoreFids);
const profile = { id: profilerNewId(), name: name, created: new Date().toISOString(), rules: rules };
profiles.push(profile);
profilerSave();
profilerScanCancel();
profilerEdit(profiles.length - 1);
} catch (e) {
errEl.textContent = e.message;
errEl.classList.remove('hidden');
} finally {
btn.disabled = false;
btn.textContent = t('Scan');
}
}
// Recursively walk the card filesystem and build rules for files that exist.
async function profilerScanCard(ignoreFids) {
const rules = [];
const seen = new Set();
// dir: { name, fid, parentSel (to select this dir), pathPrefix (rule-path segs for its children) }
async function walkDir(dir) {
let body = { name: dir.name, fid: dir.fid };
if (dir.parentSel) body.parent_sel = dir.parentSel;
let data;
try {
data = await pysimFetch('/api/tree', body);
} catch (e) { return; }
if (!data || data.exists === false) return;
for (const c of (data.children || [])) {
// parent selector for c, mirroring getParentSel()
const parentSel = (dir.name === 'MF') ? 'MF'
: (dir.name && dir.name.startsWith('ADF.')) ? dir.name
: (dir.fid ? dir.fid : dir.name);
if (c.isDir) {
// ADF roots use the AID, not the generic ADF fid
const childPrefix = c.aid ? [c.aid.toUpperCase()]
: dir.pathPrefix.concat(c.fid ? c.fid.toUpperCase() : c.name);
await walkDir({ name: c.name, fid: c.fid, parentSel: parentSel, pathPrefix: childPrefix });
} else {
const childPath = dir.pathPrefix.concat(c.fid ? c.fid.toUpperCase() : c.name).join('/');
if (seen.has(childPath)) continue;
seen.add(childPath);
const rule = await profilerBuildFileRule(childPath, c, ignoreFids);
if (rule) rules.push(rule);
}
}
}
// MF-rooted walk
await walkDir({ name: 'MF', fid: null, parentSel: null, pathPrefix: ['MF'] });
// Sweep custom files not already covered (map leading MF fid 3F00 -> MF)
for (const cf of pysimCustomFiles) {
const parts = cf.path.split('/').filter(Boolean);
if (!parts.length) continue;
let segs;
if (parts[0].toUpperCase() === '3F00') segs = ['MF'].concat(parts.slice(1));
else segs = parts;
const path = segs.join('/');
if (seen.has(path)) continue;
seen.add(path);
const rule = await profilerBuildFileRule(path, { fid: cf.fid, name: cf.name }, ignoreFids);
if (rule) rules.push(rule);
}
return rules;
}
async function profilerBuildFileRule(path, c, ignoreFids) {
let sel;
try {
sel = await pysimFetch('/api/select', { path: path });
} catch (e) { return null; }
if (!sel || sel.exists !== true) return null;
const isRecord = profilerContentKindForFileType(sel.file_type) === 'record';
const rule = {
type: 'file',
path: path,
fileType: sel.file_type || null,
fileSize: isRecord ? null : ((sel.file_size === null || sel.file_size === undefined) ? null : sel.file_size),
recordLen: (sel.record_len === null || sel.record_len === undefined) ? null : sel.record_len,
numRecords: (sel.num_of_rec === null || sel.num_of_rec === undefined) ? null : sel.num_of_rec,
content: null,
};
const fid = (c.fid || sel.fid || '').toUpperCase();
if (ignoreFids.has(fid)) return rule;
try {
const rd = await pysimFetch('/api/read', { path: path, mode: 'raw' });
if (rd && rd.success) {
if (rd.records) {
rule.content = {
mode: 'exact', kind: 'record',
records: rd.records.map(r => ({ num: r.num, data: r.data })),
};
} else if (rd.data) {
if (PROFILER_MASK_PREFIX4_FIDS[fid]) {
rule.content = { mode: 'mask', kind: 'transparent', expected: profilerMaskPrefix4(rd.data) };
} else {
rule.content = { mode: 'exact', kind: 'transparent', expected: rd.data };
}
}
}
} catch (e) {}
return rule;
}
function profilerImportFile(input) {
const file = input.files && input.files[0];
input.value = '';
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const obj = JSON.parse(reader.result);
const err = profilerValidateProfile(obj);
if (err) throw new Error(err);
obj.id = profilerNewId();
obj.created = obj.created || new Date().toISOString();
profiles.push(obj);
profilerSave();
profilerRenderList();
} catch (e) {
alert('Import error: ' + e.message);
}
};
reader.readAsText(file);
}
function profilerExport(i) {
const p = profiles[i];
downloadJson((p.name || 'profile').replace(/[^a-zA-Z0-9_-]+/g, '_') + '.json', p);
}
function profilerDelete(i) {
const p = profiles[i];
if (!confirm(t('Delete profile') + ' "' + p.name + '"?') ) return;
profiles.splice(i, 1);
profilerSave();
profilerRenderList();
}
function profilerEdit(i) {
const p = profiles[i];
profilerEditId = p.id;
profilerDraft = JSON.parse(JSON.stringify(p));
profilerSetView('editor');
document.getElementById('profiler-name').value = profilerDraft.name;
profilerRenderEditor();
}
function profilerBackToList() {
profilerDraft = null;
profilerEditId = null;
profilerSetView('list');
}
function profilerAddRule() {
if (!profilerDraft) return;
profilerDraft.rules.push({ type: 'file', path: '', fileType: null, fileSize: null, recordLen: null, numRecords: null, content: null });
profilerRenderEditor();
}
function profilerDeleteRule(i) {
if (!profilerDraft) return;
profilerDraft.rules.splice(i, 1);
profilerRenderEditor();
}
function profilerRenderEditor() {
if (!profilerDraft) return;
const el = document.getElementById('profiler-rules');
if (!profilerDraft.rules.length) {
el.innerHTML = '<span class="text-gray-400">' + t('No rules defined.') + '</span>';
return;
}
let html = '';
for (let i = 0; i < profilerDraft.rules.length; i++) {
const r = profilerDraft.rules[i];
html += profilerRenderRule(i, r);
}
el.innerHTML = html;
}
function profilerRenderRule(i, r) {
const fts = { transparent: 'transparent', linear_fixed: 'linear_fixed', cyclic: 'cyclic', ber_tlv: 'ber_tlv', df: 'df' };
let ftOpts = '<option value=""' + (!r.fileType ? ' selected' : '') + '>—</option>';
for (const k in fts) {
ftOpts += '<option value="' + k + '"' + (r.fileType === k ? ' selected' : '') + '>' + fts[k] + '</option>';
}
const cm = r.content ? r.content.mode : 'exact';
let html = '<div class="mb-2 p-3 border border-gray-200 dark:border-slate-600 rounded">';
html += '<div class="flex items-center gap-2 mb-2">';
html += '<span class="text-xs font-semibold text-gray-500 dark:text-slate-400">#' + (i + 1) + '</span>';
html += '<button onclick="profilerDeleteRule(' + i + ')" class="ml-auto px-2 py-0.5 text-xs rounded bg-red-600 text-white hover:bg-red-700">' + esc(t('Delete')) + '</button>';
html += '</div>';
html += '<div class="grid grid-cols-1 gap-2 mb-2">';
html += '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('Path')) + '</label>' +
'<input value="' + escHtml(r.path || '') + '" oninput="profilerUpdateRule(' + i + ',\'path\',this.value)" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="MF/7F10/6F3A"></div>';
html += '</div>';
const vis = profilerFileFields(r.fileType);
html += '<div class="grid grid-cols-2 md:grid-cols-4 gap-2 mb-2">';
html += '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('File type')) + '</label>' +
'<select onchange="profilerUpdateRuleFileType(' + i + ',this.value||null)" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' + ftOpts + '</select></div>';
if (vis.size) {
html += '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('Size')) + '</label>' +
'<input type="number" value="' + (r.fileSize === null ? '' : r.fileSize) + '" oninput="profilerUpdateRule(' + i + ',\'fileSize\',this.value===\'\'?null:parseInt(this.value))" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="—"></div>';
}
if (vis.records) {
html += '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('Record length')) + '</label>' +
'<input type="number" value="' + (r.recordLen === null ? '' : r.recordLen) + '" oninput="profilerUpdateRule(' + i + ',\'recordLen\',this.value===\'\'?null:parseInt(this.value))" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="—"></div>';
html += '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('Record count')) + '</label>' +
'<input type="number" value="' + (r.numRecords === null ? '' : r.numRecords) + '" oninput="profilerUpdateRule(' + i + ',\'numRecords\',this.value===\'\'?null:parseInt(this.value))" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="—"></div>';
}
html += '</div>';
if (r.fileType !== 'df') {
html += profilerRenderContentEditor(i, r);
}
html += '</div>';
return html;
}
function profilerRenderContentEditor(i, r) {
const c = r.content;
const mode = c ? c.mode : 'exact';
let html = '<div class="mb-1 text-xs text-gray-600 dark:text-slate-400">' + esc(t('Contents')) + '</div>';
html += '<div class="flex items-center gap-3 mb-1">';
html += '<label class="text-xs flex items-center gap-1"><input type="radio" name="profiler-cm-' + i + '" value="none"' + (!c ? ' checked' : '') + ' onchange="profilerSetContent(' + i + ',null)"> ' + esc(t('None')) + '</label>';
html += '<label class="text-xs flex items-center gap-1"><input type="radio" name="profiler-cm-' + i + '" value="exact"' + (c && mode === 'exact' ? ' checked' : '') + ' onchange="profilerSetContent(' + i + ',\'exact\')"> ' + esc(t('Exact')) + '</label>';
html += '<label class="text-xs flex items-center gap-1"><input type="radio" name="profiler-cm-' + i + '" value="mask"' + (c && mode === 'mask' ? ' checked' : '') + ' onchange="profilerSetContent(' + i + ',\'mask\')"> ' + esc(t('Mask')) + '</label>';
html += '</div>';
if (c) {
if (c.kind === 'record') {
html += '<div class="space-y-1">';
for (let ri = 0; ri < c.records.length; ri++) {
const rec = c.records[ri];
html += '<div class="flex items-center gap-2"><span class="text-xs text-gray-500 w-8">' + rec.num + '</span>' +
'<input value="' + escHtml(rec.data) + '" oninput="profilerUpdateRecord(' + i + ',' + ri + ',this.value)" class="flex-1 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800">';
if (c.records.length > 1) {
html += '<button onclick="profilerDeleteRecord(' + i + ',' + ri + ')" class="text-red-400 hover:text-red-600 text-xs px-1">✕</button>';
}
html += '</div>';
}
html += '</div>';
html += '<button onclick="profilerAddRecord(' + i + ')" class="mt-1 text-xs px-2 py-0.5 rounded bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300">+ Record</button>';
} else {
html += '<textarea oninput="profilerUpdateRuleContent(' + i + ',this.value)" rows="2" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800">' + escHtml(c.expected || '') + '</textarea>';
}
}
return html;
}
function profilerUpdateRule(i, field, value) {
if (!profilerDraft) return;
profilerDraft.rules[i][field] = value;
}
function profilerUpdateRuleFileType(i, value) {
if (!profilerDraft) return;
const r = profilerDraft.rules[i];
r.fileType = value;
const vis = profilerFileFields(value);
if (!vis.size) r.fileSize = null;
if (!vis.records) { r.recordLen = null; r.numRecords = null; }
if (r.content) {
const kind = profilerContentKindForFileType(value);
if (r.content.kind !== kind) {
r.content = kind === 'record'
? profilerEmptyRecordContent(r.content.mode)
: { mode: r.content.mode, kind: 'transparent', expected: '' };
}
}
profilerRenderEditor();
}
function profilerSetContent(i, mode) {
if (!profilerDraft) return;
const r = profilerDraft.rules[i];
if (!mode) {
r.content = null;
} else if (r.content) {
r.content.mode = mode;
} else {
const kind = profilerContentKindForFileType(r.fileType);
r.content = kind === 'record'
? profilerEmptyRecordContent(mode)
: { mode: mode, kind: 'transparent', expected: '' };
}
profilerRenderEditor();
}
function profilerUpdateRuleContent(i, value) {
if (!profilerDraft) return;
const c = profilerDraft.rules[i].content;
if (c) c.expected = value;
}
function profilerUpdateRecord(i, ri, value) {
if (!profilerDraft) return;
const c = profilerDraft.rules[i].content;
if (c && c.records) c.records[ri].data = value;
}
function profilerAddRecord(i) {
if (!profilerDraft) return;
const c = profilerDraft.rules[i].content;
if (!c || c.kind !== 'record') return;
const lastNum = c.records.length ? c.records[c.records.length - 1].num : 0;
c.records.push({ num: lastNum + 1, data: '' });
profilerRenderEditor();
}
function profilerDeleteRecord(i, ri) {
if (!profilerDraft) return;
const c = profilerDraft.rules[i].content;
if (!c || c.kind !== 'record') return;
if (c.records.length <= 1) return;
c.records.splice(ri, 1);
profilerRenderEditor();
}
function profilerSaveProfile() {
if (!profilerDraft) return;
profilerDraft.name = document.getElementById('profiler-name').value.trim();
const idx = profiles.findIndex(p => p.id === profilerEditId);
if (idx >= 0) profiles[idx] = profilerDraft;
profilerSave();
profilerSetView('list');
}
async function profilerCheck(i) {
const p = profiles[i];
profilerSetView('results');
document.getElementById('profiler-results-title').textContent = p.name;
const prog = document.getElementById('profiler-progress');
const summary = document.getElementById('profiler-summary');
const report = document.getElementById('profiler-report');
report.innerHTML = '';
summary.innerHTML = '';
const results = [];
let passed = 0, failed = 0, errors = 0;
for (let ri = 0; ri < p.rules.length; ri++) {
const r = p.rules[ri];
prog.textContent = t('Checking') + ' ' + (ri + 1) + '/' + p.rules.length + ' — ' + r.path;
const res = await profilerRunRule(r);
results.push(res);
if (res.status === 'pass') passed++;
else if (res.status === 'fail') failed++;
else errors++;
}
prog.textContent = '';
summary.innerHTML = '<span class="text-emerald-600 font-semibold">' + passed + ' ' + t('passed') + '</span>' +
' · <span class="text-red-600 font-semibold">' + failed + ' ' + t('failed') + '</span>' +
' · <span class="text-yellow-600 font-semibold">' + errors + ' ' + t('errors') + '</span>';
report.innerHTML = profilerRenderReport(results);
}
async function profilerRunRule(r) {
const res = { path: r.path, name: null, status: 'pass', checks: [] };
let sizeMismatch = false;
let sel;
try {
sel = await pysimFetch('/api/select', { path: r.path });
} catch (e) {
res.status = 'error';
res.error = e.message;
return res;
}
if (sel && sel.name) res.name = sel.name;
if (!sel || sel.exists !== true) {
res.status = 'fail';
res.checks.push({ label: 'exists', expected: true, actual: false, ok: false });
return res;
}
res.checks.push({ label: 'exists', expected: true, actual: true, ok: true });
if (r.fileType) {
const ok = sel.file_type === r.fileType;
if (!ok) res.status = 'fail';
res.checks.push({ label: 'fileType', expected: r.fileType, actual: sel.file_type, ok: ok });
}
const isRecord = profilerContentKindForFileType(r.fileType) === 'record';
if (!isRecord && r.fileSize !== null && r.fileSize !== undefined) {
const ok = sel.file_size === r.fileSize;
if (!ok) { res.status = 'fail'; sizeMismatch = true; }
res.checks.push({ label: 'fileSize', expected: r.fileSize, actual: sel.file_size, ok: ok });
}
if (r.recordLen !== null && r.recordLen !== undefined) {
const ok = sel.record_len === r.recordLen;
if (!ok) { res.status = 'fail'; sizeMismatch = true; }
res.checks.push({ label: 'recordLen', expected: r.recordLen, actual: sel.record_len, ok: ok });
}
if (r.numRecords !== null && r.numRecords !== undefined) {
const ok = sel.num_of_rec === r.numRecords;
if (!ok) { res.status = 'fail'; sizeMismatch = true; }
res.checks.push({ label: 'numRecords', expected: r.numRecords, actual: sel.num_of_rec, ok: ok });
}
if (r.content) {
let rd;
try {
rd = await pysimFetch('/api/read', { path: r.path, mode: 'raw' });
} catch (e) {
res.status = 'error';
res.error = e.message;
return res;
}
if (!rd || !rd.success) {
res.status = 'fail';
res.checks.push({ label: 'content', expected: 'readable', actual: 'read failed (' + (rd && rd.sw ? rd.sw : '?') + ')', ok: false });
} else if (r.content.kind === 'record') {
const exp = r.content.records || [];
const act = rd.records || [];
const n = Math.min(exp.length, act.length);
if (exp.length !== act.length) {
res.status = 'fail';
res.checks.push({ label: 'content.records', expected: exp.length + ' records', actual: act.length + ' records', ok: false });
}
for (let j = 0; j < n; j++) {
const dataOk = sizeMismatch ? profilerMatchMin(r.content.mode, exp[j].data, act[j].data) : profilerMatch(r.content.mode, exp[j].data, act[j].data);
const ok = exp[j].num === act[j].num && dataOk;
if (!ok) res.status = 'fail';
res.checks.push({ label: 'content.rec' + exp[j].num, expected: exp[j].data, actual: act[j].data, ok: ok });
}
} else {
const ok = sizeMismatch ? profilerMatchMin(r.content.mode, r.content.expected, rd.data) : profilerMatch(r.content.mode, r.content.expected, rd.data);
if (!ok) res.status = 'fail';
res.checks.push({ label: 'content', expected: r.content.expected, actual: rd.data, ok: ok });
}
}
return res;
}
function profilerCustomNameForPath(path) {
if (!path) return null;
const norm = path.toUpperCase();
for (const c of pysimCustomFiles) {
const parts = c.path.split('/').filter(Boolean);
if (!parts.length) continue;
let segs;
if (parts[0].toUpperCase() === '3F00') segs = ['MF'].concat(parts.slice(1));
else segs = parts;
if (segs.join('/').toUpperCase() === norm) return c.name;
}
return null;
}
function profilerRenderReport(results) {
let html = '';
for (const r of results) {
const color = r.status === 'pass' ? 'text-emerald-600' : (r.status === 'fail' ? 'text-red-600' : 'text-yellow-600');
const icon = r.status === 'pass' ? '✓' : (r.status === 'fail' ? '✗' : '⚠');
const name = profilerCustomNameForPath(r.path) || r.name;
const title = name
? '<span>' + esc(name) + '</span> <span class="text-gray-400 dark:text-slate-500">(' + esc(r.path) + ')</span>'
: '<span>' + esc(r.path) + '</span>';
html += '<div class="mb-2 p-2 border border-gray-200 dark:border-slate-600 rounded">';
html += '<div class="flex items-center gap-2"><span class="' + color + ' font-bold">' + icon + '</span><span class="font-mono text-sm">' + title + '</span></div>';
if (r.error) html += '<div class="text-xs text-yellow-600 mt-1">' + esc(r.error) + '</div>';
for (const c of r.checks) {
if (c.ok) continue;
html += '<div class="text-xs text-red-600 mt-1">' + esc(c.label) + ': ' + esc(t('expected')) + ' <b>' + esc(String(c.expected)) + '</b>, ' + esc(t('actual')) + ' <b>' + esc(String(c.actual)) + '</b></div>';
}
html += '</div>';
}
return html;
}
profilerLoad();
// 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',
'Application / Instance AID (hex)': 'AID приложения / экземпляра (hex)',
'Load File AID / Package AID (hex)': 'AID Load File / пакета (hex)',
'Executable Module AID / Applet Class 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': 'Удалить',
'Delete': 'Удалить',
'Delete All': 'Удалить все',
'ISD (Issuer Security Domain)': 'ISD (домен безопасности эмитента)',
'Applications / Applet Instances': 'Приложения / экземпляры апплетов',
'Executable Load Files (ELFs) / Packages': 'Исполняемые файлы загрузки (ELF) / пакеты',
'Application / Instance AID:': 'AID приложения / экземпляра:',
'Load File AID / Package AID:': 'AID Load File / пакета:',
'Executable Module AIDs / Applet Class AIDs:': 'AID исполняемых модулей / классов апплетов:',
'No cards defined.': 'Карты не заданы.',
'Card presets': 'Выбор карты',
'Card preset': 'Пресет карты',
'— Select card —': '— Выберите карту —',
'Operation': 'Операция',
'Explore Card (all GP data)': 'Обзор карты (все данные GP)',
'Install Package (.cap file)': 'Установка пакета (.cap файл)',
'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 и опрос',
'STK menu': 'Меню STK',
'No menu set by the card': 'Меню не задано картой',
'Polling': 'Опрос',
'STATUS...': 'STATUS...',
'Saving...': 'Сохранение...',
'Saved': 'Сохранено',
'Save': 'Сохранить',
'Profiler': 'Профайлер',
'New profile': 'Новый профиль',
'Profile from card': 'Профиль с карты',
'Import profile': 'Импорт профиля',
'Back to list': 'Назад к списку',
'Add rule': 'Добавить правило',
'Scan': 'Сканировать',
'Ignore contents of:': 'Игнорировать содержимое:',
'Ignore contents of': 'Игнорировать содержимое',
'No profiles defined.': 'Профили не заданы.',
'rules': 'правил',
'Check': 'Проверить',
'Export': 'Экспорт',
'Profile name': 'Имя профиля',
'Please enter a profile name': 'Введите имя профиля',
'Scanning...': 'Сканирование...',
'Delete profile': 'Удалить профиль',
'No rules defined.': 'Правила не заданы.',
'Path': 'Путь',
'File type': 'Тип файла',
'Size': 'Размер',
'Record length': 'Длина записи',
'Record count': 'Кол-во записей',
'Contents': 'Содержимое',
'None': 'Нет',
'Exact': 'Точное',
'Mask': 'Маска',
'Checking': 'Проверка',
'passed': 'пройдено',
'failed': 'не пройдено',
'errors': 'ошибок',
'expected': 'ожидалось',
'actual': 'фактически',
};
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();
chainInit('chain-sim');
chainInit('chain-usim');
chainInit('chain-ram');
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>