net-sim: network-condition simulation in the Phone tab (v2.5.0)

Server (pysim_simple_server/netsim.py + POST /api/net-sim):
- pure builders for the observed phone write vocabulary (UICC_NAA.md §13):
  EPSNSC (A0 TLV with KSI/KASME/counts/algo, padded to the card's FCP record
  size; invalidate-wipe and invalidate-keep-key), LOCI/PSLOCI/EPSLOCI real
  and dummy forms (PLMN kept, LAC FFFE, status 01/02), Kc/KcGPRS (9-byte
  USIM and 33-byte GSM forms, 07 invalidates), EF.SMSstatus counter bump
  (read-modify-write), CBMI/CBMIR, the Location status event (9B 01 <st>,
  with the optional 13 07 location info for normal service) and the
  AUTHENTICATE APDU (TS 31.102 7.1.2.1) with DB/DC response parsing
- scenario runner executes the recipes under _CARD_LOCK, picks the first
  existing candidate path (ADF.USIM vs DF.GSM/DF.TELECOM), reads FCP/current
  records where the format is card-specific, skips the event unless the card
  subscribed to Location status, honours the scenario toggles and returns a
  per-step log; only D6/DC, ENVELOPE and AUTHENTICATE are ever sent
- GET /api/mcc-mnc serves the optional worldwide operator list
  (--mcc-mnc-list, default <workspace>/samples/mcc-mnc-list.json): ?q=
  search and ?random=1&exclude= for roaming; the list stays out of the repo

PWA:
- Phone pill gains a 'Network simulation' fieldset: one button per scenario
  (cold boot, EPS/2G attach, service lost, limited service, roaming denied,
  churn, SMS received, CB reconfig, AUTHENTICATE), a collapsed Parameters
  block (operator search + random roaming, LAC/Cell ID/TAC/RAC, optional
  identities, toggles, churn count/delay) and a step log with SWs
- i18n EN/RU, help/README/api.md/AGENTS.md updated; version 2.5.0,
  SW cache simple-v193

Tests: tests/test_netsim.py (19), frontend/tests/netsim.test.js (3) and
html guards - 423 frontend / 280 Python, all passing
This commit is contained in:
2026-09-19 22:24:57 +03:00
parent 158f1c676f
commit 0d92ea8f24
15 changed files with 1571 additions and 4 deletions
+2
View File
@@ -595,6 +595,8 @@ The **Phone simulator** tab provides real-time CAT session interaction. It has t
Values persist on the server until restart. Apply → hex updates; Save → POSTs to server. The server will use these values to populate TERMINAL RESPONSE data for future PLI proactive commands.
**Network simulation** — replays the card-facing write patterns of a real phone on network-condition changes (trace study: `projects/UICC_NAA.md`): **Cold boot**, **EPS attach**, **2G attach**, **Service lost**, **Limited service**, **Roaming denied**, **Churn**, **SMS received**, **CB reconfig** and **AUTHENTICATE**. Each scenario sends the Location status event (only when the card subscribed to it), updates the EPS NAS context, location files, Kc and CB/SMS files exactly as observed, and logs every step with its SW. Parameters (collapsed) cover the operator (searchable worldwide MCC/MNC list served by the server, plus a random roaming picker), LAC/Cell ID/TAC/RAC, optional identity values (empty = random: TMSI, GUTI, KSI, KASME, Kc, NAS counts, algorithm, RAND/AUTN), scenario toggles and the churn count/delay. Only UPDATE BINARY/RECORD, ENVELOPE and AUTHENTICATE are sent; FPLMN and 5GS location files are never touched. The operator list comes from `--mcc-mnc-list` (default `<workspace>/samples/mcc-mnc-list.json`).
## SCP81
The **SCP81** tab drives HTTP OTA (GP RAM over HTTP, GPC v2.2 Amendment B) and has two pills: **Listener** and **Scripts**.
+2
View File
@@ -565,6 +565,8 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
Значения сохраняются на сервере до перезапуска. Apply → hex обновляется; Save → POST на сервер.
**Симуляция сети** — воспроизводит шаблоны записи реального телефона при смене сетевых условий (исследование трасс: `projects/UICC_NAA.md`): **Холодная загрузка**, **Подключение EPS**, **Подключение 2G**, **Потеря сервиса**, **Ограниченный сервис**, **Роуминг запрещён**, **Серия переподключений**, **Принято SMS**, **Перенастройка CB** и **AUTHENTICATE**. Каждый сценарий отправляет событие Location status (только если карта на него подписана), обновляет контекст EPS NAS, location-файлы, Kc и файлы CB/SMS в точности как в трассах и журналирует каждый шаг с его SW. Параметры (свёрнуты) задают оператора (поиск по мировому списку MCC/MNC с сервера плюс случайный роуминг-оператор), LAC/Cell ID/TAC/RAC, необязательные идентификаторы (пусто = случайно: TMSI, GUTI, KSI, KASME, Kc, счётчики NAS, алгоритм, RAND/AUTN), переключатели и число циклов/задержку. Отправляются только UPDATE BINARY/RECORD, ENVELOPE и AUTHENTICATE; FPLMN и 5GS location-файлы не затрагиваются. Список операторов задаётся `--mcc-mnc-list` (по умолчанию `<workspace>/samples/mcc-mnc-list.json`).
## SCP81
Вкладка **SCP81** управляет HTTP OTA (GP RAM over HTTP, GPC v2.2 Amendment B) и содержит две подвкладки: **Listener** и **Scripts**.
+33
View File
@@ -40,6 +40,8 @@ a 2.x PWA).
| `/api/stk-status` | GET | STK session state (active/pending/type) |
| `/api/events` | GET | Event list from SET UP EVENT LIST |
| `/api/event-send` | POST | Send ENVELOPE(Event Download) |
| `/api/net-sim` | POST | Run a network-condition scenario (attach, service loss, roaming, churn, 2G, SMS, CB, AUTHENTICATE) |
| `/api/mcc-mnc` | GET | Search the optional MCC/MNC operator list (`?q=`; `?random=1&exclude=`) |
| `/api/proactive-log` | GET | Last 50 proactive commands |
| `/api/status-poll` | POST | Manual STATUS poll + FETCH if 91XX |
| `/api/rescue` | POST | Re-send TERMINAL PROFILE to recover CAT session |
@@ -95,6 +97,15 @@ Runs pySim's `cardinfo` command and returns its output as `{"output": "..."}`
(card type, ATR, ICCID and other information pySim reports for the equipped
card). A shortcut for `POST /api/command` with `{"cmd": "cardinfo"}`.
### `GET /api/mcc-mnc`
Searches the optional worldwide operator list loaded from
`--mcc-mnc-list` (default `<workspace>/samples/mcc-mnc-list.json`).
`?q=<text>` matches country, country code, MCC/MNC, brand and operator
(compact results, max 50); `?random=1[&exclude=MCCMNC]` returns one random
entry (for roaming tests). When the list is not configured or missing the
response is `{"available": false}`.
### `POST /api/command`
Execute any pysim-shell command.
@@ -405,6 +416,28 @@ event byte values, or `[]` when none was received).
Sends an `ENVELOPE(Event Download)` for a subscribed event.
### `POST /api/net-sim`
Runs one network-condition scenario from `projects/UICC_NAA.md` section 13
against the equipped card and returns the step log:
```json
{"scenario": "service_lost", "mcc": "262", "mnc": "01", "lac": "6CD7",
"send_event": true, "dummy_locations": true, "keep_kasme": true}
```
Scenarios: `cold_boot`, `attach_eps`, `attach_2g`, `service_lost`,
`limited_service`, `roaming_denied`, `churn`, `sms_received`, `cb_reconfig`,
`authenticate`. Optional parameters: `mcc`/`mnc` (or `plmn`), `lac`,
`cell_id`, `tac`, `rac`, `tmsi`, `ptmsi`, `ptmsi_sig`, `guti`, `ksi`,
`kasme`, `ul`, `dl`, `algo`, `kc`, `rand`, `autn`, `churn_count`,
`churn_delay_ms`, and the toggles `send_event`, `dummy_locations`,
`invalidate_epsnsc`, `keep_kasme`, `write_kc`, `sms_location`, `cb_clear`
(empty identity values are randomized). The event step is skipped when the
card did not subscribe to Location status; only UPDATE BINARY/RECORD,
ENVELOPE and AUTHENTICATE are sent (never FPLMN/5GS location files). The
response is `{success, error, steps:[{action, file, path, data, sw, ok}]}`.
```json
{"event_type": 4, "event_data": "01A0"}
```
+13
View File
@@ -474,6 +474,19 @@
</ul>
<p class="text-sm mb-3">Значения хранятся на сервере до перезапуска. Когда карта выдаёт PLI, сервер вставляет значения словаря в TERMINAL RESPONSE.</p>
<h3 id="network-simulation" class="text-lg font-medium mb-2">8.7 Симуляция сети</h3>
<p class="text-sm mb-2">Воспроизводит шаблоны записи реального телефона при смене сетевых условий (исследование трасс в <code class="font-mono text-sm">projects/UICC_NAA.md</code>): подключение EPS, потеря сервиса / ограниченный сервис, запрет роуминга, откат на 2G, приём SMS, перенастройка cell broadcast и AUTHENTICATE. По кнопке на сценарий; карта должна быть подключена.</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Холодная загрузка</strong> — инвалидация EPSNSC (KSI 07, ключ стирается) и фиктивные location-файлы.</li>
<li><strong>Подключение EPS / 2G</strong> — запись реального контекста EPS NAS (KSI, KASME, счётчики NAS, алгоритм) и реальных LOCI/PSLOCI/EPSLOCI (в 2G дополнительно реальные Kc/KcGPRS).</li>
<li><strong>Потеря сервиса / ограниченный сервис / запрет роуминга</strong> — событие Location status (только если карта на него подписана), инвалидация EPSNSC (по желанию с сохранением старого KASME), фиктивные location-файлы (PLMN сохраняется, LAC <code class="font-mono text-sm">FFFE</code>, статус <code class="font-mono text-sm">01</code>/<code class="font-mono text-sm">02</code>) и инвалидация Kc.</li>
<li><strong>Серия переподключений</strong> — реальная → невалидная запись EPSNSC подряд (число циклов и задержка настраиваются).</li>
<li><strong>Принято SMS</strong> — инкремент счётчика EF.SMSstatus (чтение-изменение-запись) и, по желанию, перезапись location-файлов.</li>
<li><strong>Перенастройка CB</strong> — запись списков CBMI/CBMIR или их очистка (все FF).</li>
<li><strong>AUTHENTICATE</strong> — команда AUTHENTICATE (3G/EPS/5G, <code class="font-mono text-sm">00 88 00 81 22</code>) с заданными или случайными RAND/AUTN и показ ответа (успех <code class="font-mono text-sm">DB</code> или ошибка синхронизации <code class="font-mono text-sm">DC</code> с AUTS).</li>
</ul>
<p class="text-sm mb-3"><strong>Параметры</strong> (свёрнуты) задают оператора (поиск по мировому списку MCC/MNC с сервера плюс выбор случайного роуминг-оператора), LAC/Cell ID/TAC/RAC, необязательные идентификаторы (пусто = случайно: TMSI, GUTI, KSI, KASME, Kc, счётчики NAS, алгоритм, RAND/AUTN), переключатели сценария и число циклов/задержку. Журнал шагов показывает каждую запись, ENVELOPE и AUTHENTICATE с их SW. Отправляются только UPDATE BINARY/RECORD, ENVELOPE и AUTHENTICATE — FPLMN и 5GS location-файлы никогда не записываются (ни один наблюдаемый телефон этого не делает); записи меняют карту и видны в последующих сравнениях снимков.</p>
</section>
<section class="mb-10">
+13
View File
@@ -474,6 +474,19 @@
</ul>
<p class="text-sm mb-3">Values persist server-side until restart. When the card issues PLI, the server injects the dictionary values into the TERMINAL RESPONSE.</p>
<h3 id="network-simulation" class="text-lg font-medium mb-2">8.7 Network simulation</h3>
<p class="text-sm mb-2">Replays the write patterns a real phone performs when the network condition changes (trace study in <code class="font-mono text-sm">projects/UICC_NAA.md</code>): EPS attach, service loss / limited service, roaming denial, 2G fallback, SMS delivery, cell-broadcast reconfiguration and AUTHENTICATE. One button per scenario; the card must be equipped.</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Cold boot</strong> — invalidate EPSNSC (KSI 07, key wiped) and dummy the location files.</li>
<li><strong>EPS attach / 2G attach</strong> — store a real EPS NAS context (KSI, KASME, NAS counts, algorithm) and write real LOCI/PSLOCI/EPSLOCI (2G also writes real Kc/KcGPRS).</li>
<li><strong>Service lost / Limited service / Roaming denied</strong> — send the Location status event (only when the card subscribed to it), invalidate EPSNSC (optionally keeping the old KASME), dummy the location files (PLMN kept, LAC <code class="font-mono text-sm">FFFE</code>, status <code class="font-mono text-sm">01</code>/<code class="font-mono text-sm">02</code>) and invalidate Kc.</li>
<li><strong>Churn</strong> — replay real → invalid EPSNSC records back-to-back (count and delay configurable).</li>
<li><strong>SMS received</strong> — bump the EF.SMSstatus counter (read-modify-write) and optionally rewrite the location files.</li>
<li><strong>CB reconfig</strong> — write the CBMI/CBMIR message-ID lists or clear them (all FF).</li>
<li><strong>AUTHENTICATE</strong> — send AUTHENTICATE (3G/EPS/5G, <code class="font-mono text-sm">00 88 00 81 22</code>) with the given or random RAND/AUTN and show the response (success <code class="font-mono text-sm">DB</code> or synchronisation failure <code class="font-mono text-sm">DC</code> with AUTS).</li>
</ul>
<p class="text-sm mb-3"><strong>Parameters</strong> (collapsed) provide the operator (searchable worldwide MCC/MNC list served from the server, plus a random roaming picker), LAC/Cell ID/TAC/RAC, optional identity values (empty = random: TMSI, GUTI, KSI, KASME, Kc, NAS counts, algorithm, RAND/AUTN), the scenario toggles and the churn count/delay. The step log lists every write, ENVELOPE and AUTHENTICATE with its SW. Only UPDATE BINARY/RECORD, ENVELOPE and AUTHENTICATE are sent — FPLMN and 5GS location files are never written (no observed phone does); the writes change the card and are visible to later snapshot comparisons.</p>
</section>
<section class="mb-10">
+274 -1
View File
@@ -943,6 +943,93 @@
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Fetched proactive commands:">Fetched proactive commands:</legend>
<div id="pysim-proactive-log" class="text-sm font-mono text-gray-600 dark:text-slate-400"></div>
</fieldset>
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3 mt-4">
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Network simulation">Network simulation</legend>
<div class="text-xs text-gray-500 dark:text-slate-400 mb-2" data-l10n="Replays the write patterns a real phone performs on network-condition changes (EPS attach, service loss, roaming denial, 2G fallback, SMS delivery, cell-broadcast reconfiguration, AUTHENTICATE) - see projects/UICC_NAA.md. Only UPDATE BINARY/RECORD, ENVELOPE and AUTHENTICATE are sent; FPLMN and 5GS location files are never touched.">Replays the write patterns a real phone performs on network-condition changes (EPS attach, service loss, roaming denial, 2G fallback, SMS delivery, cell-broadcast reconfiguration, AUTHENTICATE) - see projects/UICC_NAA.md. Only UPDATE BINARY/RECORD, ENVELOPE and AUTHENTICATE are sent; FPLMN and 5GS location files are never touched.</div>
<div class="flex flex-wrap gap-1.5 mb-2">
<button data-needs="card" onclick="netSimRun('cold_boot')" class="px-2.5 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Cold boot">Cold boot</button>
<button data-needs="card" onclick="netSimRun('attach_eps')" class="px-2.5 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="EPS attach">EPS attach</button>
<button data-needs="card" onclick="netSimRun('attach_2g')" class="px-2.5 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="2G attach">2G attach</button>
<button data-needs="card" onclick="netSimRun('service_lost')" class="px-2.5 py-1 text-xs rounded bg-red-600 text-white hover:bg-red-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Service lost">Service lost</button>
<button data-needs="card" onclick="netSimRun('limited_service')" class="px-2.5 py-1 text-xs rounded bg-amber-600 text-white hover:bg-amber-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Limited service">Limited service</button>
<button data-needs="card" onclick="netSimRun('roaming_denied')" class="px-2.5 py-1 text-xs rounded bg-amber-600 text-white hover:bg-amber-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Roaming denied">Roaming denied</button>
<button data-needs="card" onclick="netSimRun('churn')" class="px-2.5 py-1 text-xs rounded bg-violet-600 text-white hover:bg-violet-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Churn">Churn</button>
<button data-needs="card" onclick="netSimRun('sms_received')" class="px-2.5 py-1 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="SMS received">SMS received</button>
<button data-needs="card" onclick="netSimRun('cb_reconfig')" class="px-2.5 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="CB reconfig">CB reconfig</button>
<button data-needs="card" onclick="netSimRun('authenticate')" class="px-2.5 py-1 text-xs rounded bg-slate-600 text-white hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="AUTHENTICATE">AUTHENTICATE</button>
</div>
<details id="netsim-params" class="mb-2">
<summary class="cursor-pointer select-none text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Parameters">Parameters</summary>
<div class="mt-2 space-y-2">
<div class="flex flex-wrap items-end gap-2">
<div class="flex-1 min-w-[14rem]">
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Operator search (country / brand / MCC-MNC)">Operator search (country / brand / MCC-MNC)</label>
<input id="netsim-op-search" oninput="netSimSearchInput()" placeholder="Telekom / Germany / 26201" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
</div>
<div>
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="MCC">MCC</label>
<input id="netsim-mcc" value="001" maxlength="3" class="w-16 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
</div>
<div>
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="MNC">MNC</label>
<input id="netsim-mnc" value="01" maxlength="3" class="w-16 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
</div>
<button data-needs="server" onclick="netSimRandomOperator()" class="px-2 py-1 text-xs rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Random roaming operator">Random roaming operator</button>
</div>
<div id="netsim-op-results" class="text-xs font-mono max-h-32 overflow-auto"></div>
<div class="flex flex-wrap items-end gap-2">
<div>
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="LAC">LAC</label>
<input id="netsim-lac" maxlength="4" placeholder="random" class="w-20 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
</div>
<button onclick="netSimDice('netsim-lac', 2)" class="px-1.5 py-1 text-xs rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" title="random">🎲</button>
<div>
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Cell ID">Cell ID</label>
<input id="netsim-cell" maxlength="4" placeholder="random" class="w-20 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
</div>
<button onclick="netSimDice('netsim-cell', 2)" class="px-1.5 py-1 text-xs rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" title="random">🎲</button>
<div>
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="TAC">TAC</label>
<input id="netsim-tac" maxlength="4" placeholder="= LAC" class="w-20 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
</div>
<button onclick="netSimDice('netsim-tac', 2)" class="px-1.5 py-1 text-xs rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" title="random">🎲</button>
<div>
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="RAC">RAC</label>
<input id="netsim-rac" maxlength="2" placeholder="random" class="w-14 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
</div>
<button onclick="netSimDice('netsim-rac', 1)" class="px-1.5 py-1 text-xs rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" title="random">🎲</button>
</div>
<details>
<summary class="cursor-pointer select-none text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Advanced identity (empty = random)">Advanced identity (empty = random)</summary>
<div class="mt-2 flex flex-wrap items-end gap-2">
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400">TMSI</label><input id="netsim-tmsi" maxlength="8" class="w-24 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400">GUTI</label><input id="netsim-guti" maxlength="24" class="w-56 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400">KSI</label><input id="netsim-ksi" maxlength="2" class="w-12 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400">KASME</label><input id="netsim-kasme" maxlength="64" class="w-72 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400">Kc</label><input id="netsim-kc" maxlength="16" class="w-32 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400">UL</label><input id="netsim-ul" maxlength="8" class="w-20 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400">DL</label><input id="netsim-dl" maxlength="8" class="w-20 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Algorithm">Algorithm</label><input id="netsim-algo" maxlength="2" class="w-12 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400">RAND</label><input id="netsim-rand" maxlength="32" class="w-64 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400">AUTN</label><input id="netsim-autn" maxlength="32" class="w-64 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
</div>
</details>
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600 dark:text-slate-300">
<label class="flex items-center gap-1"><input type="checkbox" id="netsim-send-event" checked class="rounded"> <span data-l10n="Send Location status event (if subscribed)">Send Location status event (if subscribed)</span></label>
<label class="flex items-center gap-1"><input type="checkbox" id="netsim-dummy-locations" checked class="rounded"> <span data-l10n="Dummy location files">Dummy location files</span></label>
<label class="flex items-center gap-1"><input type="checkbox" id="netsim-invalidate-epsnsc" checked class="rounded"> <span data-l10n="Invalidate EPSNSC">Invalidate EPSNSC</span></label>
<label class="flex items-center gap-1"><input type="checkbox" id="netsim-keep-kasme" checked class="rounded"> <span data-l10n="Keep KASME (KSI 07 only)">Keep KASME (KSI 07 only)</span></label>
<label class="flex items-center gap-1"><input type="checkbox" id="netsim-write-kc" checked class="rounded"> <span data-l10n="Write/invalidate Kc">Write/invalidate Kc</span></label>
<label class="flex items-center gap-1"><input type="checkbox" id="netsim-sms-location" checked class="rounded"> <span data-l10n="SMS: rewrite location too">SMS: rewrite location too</span></label>
<label class="flex items-center gap-1"><input type="checkbox" id="netsim-cb-clear" class="rounded"> <span data-l10n="CB: clear lists instead of writing IDs">CB: clear lists instead of writing IDs</span></label>
<label class="flex items-center gap-1"><span data-l10n="Churn count">Churn count</span><input id="netsim-churn-count" type="number" value="3" min="1" max="20" class="w-14 border border-gray-300 dark:border-slate-600 text-xs rounded px-1 py-0.5 dark:bg-slate-800"></label>
<label class="flex items-center gap-1"><span data-l10n="Delay ms">Delay ms</span><input id="netsim-churn-delay" type="number" value="150" min="0" max="5000" class="w-20 border border-gray-300 dark:border-slate-600 text-xs rounded px-1 py-0.5 dark:bg-slate-800"></label>
</div>
</div>
</details>
<div id="netsim-status" class="text-xs text-gray-400 dark:text-slate-500 mb-1"></div>
<div id="netsim-log" class="text-xs font-mono text-gray-600 dark:text-slate-400 max-h-[30vh] overflow-auto"></div>
</fieldset>
</div>
<div id="phone-sub-tr" class="hidden">
<div class="flex justify-between items-center mb-2">
@@ -1301,7 +1388,7 @@
// ===== Version =====
// Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect().
const SIMPLE_VERSION = '2.4.0';
const SIMPLE_VERSION = '2.5.0';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching =====
@@ -12231,6 +12318,160 @@ function efContentDiff(name, fid, expectedHex, actualHex) {
return { rows: rows, sameDecoded: rows.length === 0 };
}
// ===== Network simulation (Phone tab) =====
// Sends the scenario parameters to POST /api/net-sim and renders the step
// log. The scenarios are implemented in pysim_simple_server/netsim.py
// (trace study: projects/UICC_NAA.md section 13).
let _netSimOpResults = [];
let _netSimSearchTimer = null;
function netSimRandomHex(nbytes) {
let s = '';
for (let i = 0; i < nbytes; i++) s += Math.floor(Math.random() * 256).toString(16).padStart(2, '0').toUpperCase();
return s;
}
function netSimDice(id, nbytes) {
const el = document.getElementById(id);
if (el) el.value = netSimRandomHex(nbytes);
}
function netSimVal(id) {
const el = document.getElementById(id);
return el ? el.value.trim() : '';
}
function netSimChk(id) {
const el = document.getElementById(id);
return el ? el.checked : false;
}
function netSimParams() {
const p = {
send_event: netSimChk('netsim-send-event'),
dummy_locations: netSimChk('netsim-dummy-locations'),
invalidate_epsnsc: netSimChk('netsim-invalidate-epsnsc'),
keep_kasme: netSimChk('netsim-keep-kasme'),
write_kc: netSimChk('netsim-write-kc'),
sms_location: netSimChk('netsim-sms-location'),
cb_clear: netSimChk('netsim-cb-clear'),
};
const map = {
mcc: 'netsim-mcc', mnc: 'netsim-mnc', lac: 'netsim-lac', cell_id: 'netsim-cell',
tac: 'netsim-tac', rac: 'netsim-rac', tmsi: 'netsim-tmsi', guti: 'netsim-guti',
ksi: 'netsim-ksi', kasme: 'netsim-kasme', kc: 'netsim-kc', ul: 'netsim-ul',
dl: 'netsim-dl', algo: 'netsim-algo', rand: 'netsim-rand', autn: 'netsim-autn',
churn_count: 'netsim-churn-count', churn_delay_ms: 'netsim-churn-delay',
};
for (const [key, id] of Object.entries(map)) {
const v = netSimVal(id);
if (v !== '') p[key] = v;
}
return p;
}
async function netSimRun(scenario) {
const statusEl = document.getElementById('netsim-status');
statusEl.textContent = t('Running') + '...';
statusEl.className = 'text-xs text-gray-500 dark:text-slate-400 mb-1';
try {
const body = netSimParams();
body.scenario = scenario;
const res = await pysimFetch('/api/net-sim', body);
netSimRenderLog(res);
if (res.success) {
statusEl.textContent = t('Done') + ' — ' + (res.steps || []).length + ' ' + t('steps');
statusEl.className = 'text-xs text-emerald-600 dark:text-emerald-400 mb-1';
} else {
statusEl.textContent = t('Failed') + ': ' + (res.error || '');
statusEl.className = 'text-xs text-red-500 mb-1';
}
pysimEventsRender();
} catch (e) {
statusEl.textContent = t('Error') + ': ' + e.message;
statusEl.className = 'text-xs text-red-500 mb-1';
}
}
function netSimStepText(s) {
const sw = (s.sw !== undefined && s.sw !== null) ? ' → SW ' + s.sw : '';
if (s.action === 'skip') return t('skip') + ' ' + (s.file || '') + (s.note ? ' — ' + s.note : '');
if (s.action === 'event') return 'ENVELOPE ' + (s.file || '') + ' ' + (s.data || '') + sw;
if (s.action === 'authenticate') {
const parsed = s.parsed ? s.parsed.type : '';
return 'AUTHENTICATE ' + parsed + sw + (s.response ? ' ' + s.response : '');
}
return s.action + ' ' + (s.file || '') + (s.path ? ' (' + s.path + ')' : '') + (s.data ? ' ' + s.data : '') + sw;
}
function netSimRenderLog(res) {
const el = document.getElementById('netsim-log');
if (!el) return;
let html = '';
for (const s of res.steps || []) {
const cls = s.ok === false ? 'text-red-500' : (s.action === 'skip' ? 'text-yellow-600' : 'text-gray-600 dark:text-slate-400');
html += '<div class="' + cls + ' break-all">' + esc(netSimStepText(s)) + '</div>';
}
el.innerHTML = html || '<span class="text-gray-400">' + esc(t('No steps')) + '</span>';
}
function netSimSearchInput() {
clearTimeout(_netSimSearchTimer);
_netSimSearchTimer = setTimeout(netSimSearch, 250);
}
async function netSimSearch() {
const q = netSimVal('netsim-op-search');
const el = document.getElementById('netsim-op-results');
if (!el) return;
if (q.length < 2) { el.innerHTML = ''; return; }
try {
const res = await pysimFetch('/api/mcc-mnc?q=' + encodeURIComponent(q));
if (!res.available) {
el.innerHTML = '<span class="text-yellow-600">' + esc(t('Operator list not loaded (server --mcc-mnc-list)')) + '</span>';
return;
}
_netSimOpResults = res.results || [];
el.innerHTML = _netSimOpResults.map((r, i) =>
'<div class="cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-700 rounded px-1" onclick="netSimPickOperator(' + i + ')">' +
esc((r.countryName || '') + ' — ' + (r.brand || r.operator || '') + ' (' + r.mcc + '/' + r.mnc + ')') + '</div>').join('');
} catch (e) {
el.textContent = e.message;
}
}
function netSimPickOperator(i) {
const r = _netSimOpResults[i];
if (!r) return;
document.getElementById('netsim-mcc').value = r.mcc;
document.getElementById('netsim-mnc').value = r.mnc;
const el = document.getElementById('netsim-op-results');
if (el) el.innerHTML = '';
}
async function netSimRandomOperator() {
const statusEl = document.getElementById('netsim-status');
try {
const exclude = (netSimVal('netsim-mcc') || '') + (netSimVal('netsim-mnc') || '');
const res = await pysimFetch('/api/mcc-mnc?random=1' + (exclude ? '&exclude=' + encodeURIComponent(exclude) : ''));
if (!res.available) {
statusEl.textContent = t('Operator list not loaded (server --mcc-mnc-list)');
statusEl.className = 'text-xs text-yellow-600 mb-1';
return;
}
if (res.result) {
document.getElementById('netsim-mcc').value = res.result.mcc;
document.getElementById('netsim-mnc').value = res.result.mnc;
statusEl.textContent = (res.result.countryName || '') + ' — ' + (res.result.brand || res.result.operator || '') + ' (' + res.result.mcc + '/' + res.result.mnc + ')';
statusEl.className = 'text-xs text-gray-500 dark:text-slate-400 mb-1';
}
} catch (e) {
statusEl.textContent = t('Error') + ': ' + e.message;
statusEl.className = 'text-xs text-red-500 mb-1';
}
}
// ===== i18n =====
const LANG_RU = {
'Generate APDU': 'Сгенерировать APDU',
@@ -12488,6 +12729,38 @@ const LANG_RU = {
'Events that the card monitors (SET UP EVENT LIST):': 'События, отслеживаемые картой (SET UP EVENT LIST):',
'No events configured.': 'Нет настроенных событий.',
'Fetched proactive commands:': 'Извлечённые проактивные команды:',
'Network simulation': 'Симуляция сети',
'Replays the write patterns a real phone performs on network-condition changes (EPS attach, service loss, roaming denial, 2G fallback, SMS delivery, cell-broadcast reconfiguration, AUTHENTICATE) - see projects/UICC_NAA.md. Only UPDATE BINARY/RECORD, ENVELOPE and AUTHENTICATE are sent; FPLMN and 5GS location files are never touched.': 'Воспроизводит шаблоны записи реального телефона при смене сетевых условий (подключение EPS, потеря сервиса, запрет роуминга, откат на 2G, приём SMS, перенастройка cell broadcast, AUTHENTICATE) — см. projects/UICC_NAA.md. Отправляются только UPDATE BINARY/RECORD, ENVELOPE и AUTHENTICATE; FPLMN и 5GS location-файлы не затрагиваются.',
'Cold boot': 'Холодная загрузка',
'EPS attach': 'Подключение EPS',
'2G attach': 'Подключение 2G',
'Service lost': 'Потеря сервиса',
'Limited service': 'Ограниченный сервис',
'Roaming denied': 'Роуминг запрещён',
'Churn': 'Серия переподключений',
'SMS received': 'Принято SMS',
'CB reconfig': 'Перенастройка CB',
'Parameters': 'Параметры',
'Operator search (country / brand / MCC-MNC)': 'Поиск оператора (страна / бренд / MCC-MNC)',
'Random roaming operator': 'Случайный роуминг-оператор',
'Advanced identity (empty = random)': 'Дополнительные идентификаторы (пусто = случайно)',
'Algorithm': 'Алгоритм',
'Send Location status event (if subscribed)': 'Отправлять событие Location status (если подписано)',
'Dummy location files': 'Фиктивные location-файлы',
'Invalidate EPSNSC': 'Инвалидировать EPSNSC',
'Keep KASME (KSI 07 only)': 'Сохранять KASME (только KSI 07)',
'Write/invalidate Kc': 'Записывать/инвалидировать Kc',
'SMS: rewrite location too': 'SMS: перезаписывать и location',
'CB: clear lists instead of writing IDs': 'CB: очищать списки вместо записи ID',
'Churn count': 'Число циклов',
'Delay ms': 'Задержка, мс',
'Running': 'Выполняется',
'Done': 'Готово',
'Failed': 'Ошибка',
'steps': 'шагов',
'No steps': 'Нет шагов',
'skip': 'пропуск',
'Operator list not loaded (server --mcc-mnc-list)': 'Список операторов не загружен (--mcc-mnc-list на сервере)',
'No commands logged yet.': 'Команды пока не регистрировались.',
'Send event notification to the card?': 'Отправить уведомление о событии на карту?',
'This event type is not yet supported.': 'Этот тип события пока не поддерживается.',
+22
View File
@@ -823,6 +823,10 @@ video {
max-height: 14rem;
}
.max-h-\[30vh\] {
max-height: 30vh;
}
.max-h-\[35vh\] {
max-height: 35vh;
}
@@ -919,6 +923,10 @@ video {
width: 1.5rem;
}
.w-64 {
width: 16rem;
}
.w-72 {
width: 18rem;
}
@@ -940,6 +948,10 @@ video {
min-width: 0px;
}
.min-w-\[14rem\] {
min-width: 14rem;
}
.min-w-\[180px\] {
min-width: 180px;
}
@@ -1265,6 +1277,11 @@ video {
border-top-color: transparent;
}
.bg-amber-600 {
--tw-bg-opacity: 1;
background-color: rgb(217 119 6 / var(--tw-bg-opacity, 1));
}
.bg-black\/50 {
background-color: rgb(0 0 0 / 0.5);
}
@@ -1693,6 +1710,11 @@ video {
color: #f1f5f9;
}
.hover\:bg-amber-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(180 83 9 / var(--tw-bg-opacity, 1));
}
.hover\:bg-blue-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--tw-bg-opacity, 1));
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v192';
const CACHE = 'simple-v193';
const URLS = [
'index.html',
'help.html',
+15
View File
@@ -202,6 +202,21 @@ test('every help anchor used by the UI exists in help.html', () => {
assert.ok(html.includes("? 'scp81-scripts' : 'scp81-listener'"));
});
test('phone simulator has the network-simulation fieldset', () => {
assert.ok(html.includes('data-l10n="Network simulation"'));
for (const s of ['cold_boot', 'attach_eps', 'attach_2g', 'service_lost',
'limited_service', 'roaming_denied', 'churn', 'sms_received',
'cb_reconfig', 'authenticate']) {
assert.ok(html.includes("netSimRun('" + s + "')"), s);
}
assert.ok(html.includes('id="netsim-op-search"'));
assert.ok(html.includes('id="netsim-log"'));
assert.ok(html.includes('id="netsim-status"'));
assert.match(html, /id="netsim-mcc" value="001"/);
// scenario buttons need the card (the server writes real EFs)
assert.match(html, /data-needs="card" onclick="netSimRun\('service_lost'\)"/);
});
test('SCP81 listener exposes its four modes with the matching notes', () => {
for (const v of ['tls', 'redirect', 'passthru', 'dump']) {
assert.ok(html.includes('value="' + v + '"'), v);
+86
View File
@@ -0,0 +1,86 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
let code = '';
for (const f of ['netSimRandomHex', 'netSimDice', 'netSimVal', 'netSimChk', 'netSimParams', 'netSimStepText']) {
code += extractFunc(html, f) + '\n';
}
code += 'function t(k){return k;}\n';
code += 'var _fields = {};\nvar document = { getElementById: function(id){ return _fields[id] || null; } };\n';
eval(code);
test('netSimRandomHex produces uppercase hex of the requested length', () => {
const h = netSimRandomHex(4);
assert.match(h, /^[0-9A-F]{8}$/);
assert.match(netSimRandomHex(1), /^[0-9A-F]{2}$/);
});
test('netSimParams maps form fields and checkboxes', () => {
_fields = {
'netsim-mcc': { value: '262' },
'netsim-mnc': { value: '01' },
'netsim-lac': { value: ' 6CD7 ' },
'netsim-lac-empty': { value: '' },
'netsim-churn-count': { value: '5' },
'netsim-send-event': { checked: true },
'netsim-dummy-locations': { checked: false },
'netsim-invalidate-epsnsc': { checked: true },
'netsim-keep-kasme': { checked: true },
'netsim-write-kc': { checked: false },
'netsim-sms-location': { checked: true },
'netsim-cb-clear': { checked: false },
};
const p = netSimParams();
assert.strictEqual(p.mcc, '262');
assert.strictEqual(p.mnc, '01');
assert.strictEqual(p.lac, '6CD7');
assert.strictEqual(p.churn_count, '5');
assert.strictEqual(p.send_event, true);
assert.strictEqual(p.dummy_locations, false);
assert.strictEqual(p.invalidate_epsnsc, true);
assert.strictEqual(p.keep_kasme, true);
assert.strictEqual(p.write_kc, false);
assert.strictEqual(p.sms_location, true);
assert.strictEqual(p.cb_clear, false);
// unset fields are omitted so the server fills its own defaults
assert.ok(!('tmsi' in p));
assert.ok(!('kasme' in p));
});
test('netSimStepText renders the step log lines', () => {
assert.strictEqual(
netSimStepText({ action: 'update_binary', file: 'loci', path: 'ADF.USIM/6F7E', data: 'FF01', sw: '9000' }),
'update_binary loci (ADF.USIM/6F7E) FF01 → SW 9000');
assert.strictEqual(
netSimStepText({ action: 'update_record', file: 'epsnsc', path: 'ADF.USIM/6FE4', data: 'A0', sw: '9000' }),
'update_record epsnsc (ADF.USIM/6FE4) A0 → SW 9000');
assert.strictEqual(
netSimStepText({ action: 'event', file: 'location_status', data: '9B0102', sw: '9000' }),
'ENVELOPE location_status 9B0102 → SW 9000');
assert.strictEqual(
netSimStepText({ action: 'skip', file: 'location_status', note: 'event 0x03 not in SET UP EVENT LIST' }),
'skip location_status — event 0x03 not in SET UP EVENT LIST');
assert.match(
netSimStepText({ action: 'authenticate', parsed: { type: 'synchronisation_failure' }, sw: '9000', response: 'DC10AA' }),
/^AUTHENTICATE synchronisation_failure → SW 9000 DC10AA$/);
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
version = "2.4.0"
version = "2.5.0"
description = "HTTP REST server wrapping pysim for the SIMple PWA"
requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
+11
View File
@@ -28,6 +28,14 @@ def _default_web_dir():
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'frontend')
def _default_mcc_mnc_list():
# Workspace default: the CC-BY-SA operator list lives outside the repo
# (<workspace>/samples/mcc-mnc-list.json); clones can point elsewhere with
# --mcc-mnc-list and the endpoint reports available=false when missing.
repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.normpath(os.path.join(repo, '..', 'samples', 'mcc-mnc-list.json'))
def main():
global _server_start
_server_start = time.time()
@@ -38,6 +46,8 @@ def main():
parser.add_argument('--http-port', type=int, default=8080, help='Bind port (default: 8080)')
parser.add_argument('--web-dir', default=_default_web_dir(), metavar='PATH',
help='Directory with the SIMple PWA static files to serve (default: <repo>/frontend)')
parser.add_argument('--mcc-mnc-list', default=_default_mcc_mnc_list(), metavar='PATH',
help='Worldwide MCC/MNC operator list (JSON) for the network-simulation operator picker (default: <workspace>/samples/mcc-mnc-list.json)')
parser.add_argument('--log-requests', action='store_true', default=False, help='Log request/response payloads to stderr')
parser.add_argument('--sms-oa', default='12345', metavar='DIGITS',
help='TP-Originating-Address (SMSC number) for the SMS-DELIVER TPDU (default: 12345)')
@@ -213,6 +223,7 @@ def main():
server.terminal_profile = opts.terminal_profile
server.cli_terminal_profile = opts.terminal_profile
server.web_dir = opts.web_dir
server.mcc_mnc_path = opts.mcc_mnc_list
server.sim_menu = sim_menu
server.event_list = event_list
server.menu_active = False
+670
View File
@@ -0,0 +1,670 @@
# coding=utf-8
"""Network-condition simulation for the SIMple lab.
Replays the card-facing write patterns of a real phone when the network
condition changes, following the trace study in ``projects/UICC_NAA.md``
(section 13): EPS attach, service loss / limited service, roaming denial,
2G fallback, SMS delivery, cell-broadcast reconfiguration and an
AUTHENTICATE exchange.
Only the observed vocabulary is used: UPDATE BINARY (D6), UPDATE RECORD (DC),
ENVELOPE (Event Download) and AUTHENTICATE. FPLMN and the 5GS location files
are never written (no observed phone does - UICC_NAA.md section 12).
The ``build_*`` functions are pure and unit-tested (tests/test_netsim.py).
``NetSimRunner`` applies a scenario to a live card through the helpers of
``pysim_simple_server.server``; the caller holds the card lock.
"""
import random
import re
import threading
import time
# Candidate paths per logical file. The first existing one is used, so a
# USIM card is served from ADF.USIM and a GSM SIM from DF.GSM/DF.TELECOM.
FILE_PATHS = {
'epsnsc': ['ADF.USIM/6FE4'],
'loci': ['ADF.USIM/6F7E', 'DF.GSM/6F7E'],
'psloci': ['ADF.USIM/6F73', 'DF.GSM/6F73'],
'epsloci': ['ADF.USIM/6FE3'],
'kc': ['ADF.USIM/4F20', 'DF.GSM/6F08'],
'kcgprs': ['ADF.USIM/4F52', 'DF.GSM/6F09'],
'smsstatus': ['ADF.USIM/6F43', 'DF.TELECOM/6F43', 'DF.GSM/6F43'],
'cbmi': ['ADF.USIM/6F45', 'DF.GSM/6F45'],
'cbmir': ['ADF.USIM/6F50', 'DF.GSM/6F50'],
}
# File status codes (LOCI/PSLOCI/EPSLOCI update status).
ST_UPDATED = 0x00
ST_NOT_UPDATED = 0x01
ST_PLMN_NOT_ALLOWED = 0x02
ST_LA_NOT_ALLOWED = 0x03
# Location status event (TS 102 223 section 8.27).
LOC_STATUS_NORMAL = 0x00
LOC_STATUS_LIMITED = 0x01
LOC_STATUS_NO_SERVICE = 0x02
EVENT_LOCATION_STATUS = 0x03
def _norm_hex(value, nbytes=None):
"""Normalize a hex string, optionally checking the byte length."""
h = re.sub(r'[^0-9a-fA-F]', '', value or '').upper()
if nbytes is not None and len(h) != nbytes * 2:
raise ValueError('expected %d hex bytes, got %r' % (nbytes, value))
return h
def _pad_ff(data, size):
"""Pad (or truncate) a byte string to `size` with 0xFF."""
if size is None or len(data) >= size:
return data[:size] if size else data
return data + b'\xFF' * (size - len(data))
def rand_hex(nbytes, rng=None):
r = rng or random
return ''.join(r.choice('0123456789ABCDEF') for _ in range(nbytes * 2))
def plmn_bcd(mcc, mnc):
"""MCC/MNC digits -> 3-byte PLMN (TS 24.008 10.5.1.13).
Nibble order: byte0 = MCC2 MCC1, byte1 = MNC3 MCC3, byte2 = MNC2 MNC1
(MNC3 = 'F' for two-digit MNCs). Cross-checked against the pySim
PLMNsel test vector 228/06 -> 22 F8 60.
"""
mcc = re.sub(r'\D', '', str(mcc or ''))
mnc = re.sub(r'\D', '', str(mnc or ''))
if len(mcc) != 3:
raise ValueError('MCC must have 3 digits, got %r' % mcc)
if len(mnc) not in (2, 3):
raise ValueError('MNC must have 2 or 3 digits, got %r' % mnc)
m = [int(c) for c in mcc]
n = [int(c) for c in mnc] + ([0xF] if len(mnc) == 2 else [])
return '%02X%02X%02X' % ((m[1] << 4) | m[0], (n[2] << 4) | m[2], (n[1] << 4) | n[0])
def _plmn_bytes(plmn_hex):
return bytes.fromhex(_norm_hex(plmn_hex, 3))
# ---- EPS NAS Security Context (EF.EPSNSC, TS 31.102 4.2.92) ----
def build_epsnsc(ksi, kasme_hex, ul, dl, algo, size=54):
"""One A0 TLV record, padded to the card's record size with FF."""
kasme = bytes.fromhex(_norm_hex(kasme_hex, 32))
inner = (bytes([0x80, 0x01, ksi & 0xFF])
+ bytes([0x81, 0x20]) + kasme
+ bytes([0x82, 0x04]) + (ul & 0xFFFFFFFF).to_bytes(4, 'big')
+ bytes([0x83, 0x04]) + (dl & 0xFFFFFFFF).to_bytes(4, 'big')
+ bytes([0x84, 0x01, algo & 0xFF]))
rec = bytes([0xA0, len(inner)]) + inner
return _pad_ff(rec, size).hex().upper()
def build_epsnsc_invalidate(size=54, kasme_hex=None, algo=0x00):
"""Invalid context: KSI 07, optional old KASME kept (UICC_NAA.md 5.2)."""
kasme = (bytes.fromhex(_norm_hex(kasme_hex, 32)) if kasme_hex
else b'\xFF' * 32)
inner = (bytes([0x80, 0x01, 0x07])
+ bytes([0x81, 0x20]) + kasme
+ bytes([0x82, 0x04]) + b'\xFF' * 4
+ bytes([0x83, 0x04]) + b'\xFF' * 4
+ bytes([0x84, 0x01, algo & 0xFF]))
rec = bytes([0xA0, len(inner)]) + inner
return _pad_ff(rec, size).hex().upper()
def parse_epsnsc_kasme(record_hex):
"""Extract the 32-byte KASME from an EPSNSC record, or None."""
try:
data = bytes.fromhex(_norm_hex(record_hex))
except ValueError:
return None
# The record is one A0 container; KASME is the 81 TLV inside it.
if len(data) >= 2 and data[0] == 0xA0:
data = data[2:2 + data[1]]
i = 0
while i + 2 <= len(data):
tag, ln = data[i], data[i + 1]
value = data[i + 2:i + 2 + ln]
if tag == 0x81 and ln == 32:
if all(b == 0xFF for b in value):
return None # wiped key = invalid context
return value.hex().upper()
i += 2 + ln
return None
# ---- Location files (TS 31.102 4.2.16 / 4.2.23 / 4.2.91) ----
def build_loci(tmsi_hex, plmn_hex, lac_hex, status=ST_UPDATED, rfu=0xFF):
"""LOCI: TMSI(4) + LAI(5) + RFU(1) + update status(1) = 11 bytes."""
return (bytes.fromhex(_norm_hex(tmsi_hex, 4)) + _plmn_bytes(plmn_hex)
+ bytes.fromhex(_norm_hex(lac_hex, 2))
+ bytes([rfu & 0xFF, status & 0xFF])).hex().upper()
def build_loci_dummy(plmn_hex):
"""Service lost: TMSI FF, PLMN kept, LAC FFFE, status 01."""
return build_loci('FFFFFFFF', plmn_hex, 'FFFE', ST_NOT_UPDATED)
def build_psloci(ptmsi_hex, sig_hex, plmn_hex, lac_hex, rac_hex,
status=ST_UPDATED):
"""PSLOCI: P-TMSI(4) + signature(3) + RAI(6) + status(1) = 14 bytes."""
return (bytes.fromhex(_norm_hex(ptmsi_hex, 4))
+ bytes.fromhex(_norm_hex(sig_hex, 3)) + _plmn_bytes(plmn_hex)
+ bytes.fromhex(_norm_hex(lac_hex, 2))
+ bytes.fromhex(_norm_hex(rac_hex, 1))
+ bytes([status & 0xFF])).hex().upper()
def build_psloci_dummy(plmn_hex):
return build_psloci('FFFFFFFF', 'FFFFFF', plmn_hex, 'FFFE', 'FF',
ST_NOT_UPDATED)
def build_epsloci(guti_hex, plmn_hex, tac_hex, status=ST_UPDATED):
"""EPSLOCI: GUTI(12) + TAI(5) + EPS update status(1) = 18 bytes."""
return (bytes.fromhex(_norm_hex(guti_hex, 12)) + _plmn_bytes(plmn_hex)
+ bytes.fromhex(_norm_hex(tac_hex, 2))
+ bytes([status & 0xFF])).hex().upper()
def build_epsloci_dummy(plmn_hex):
"""GUTI header 0B F6 + PLMN, identity wiped; TAI PLMN + FFFF; status 01."""
return build_epsloci('0BF6' + plmn_hex + 'FF' * 7, plmn_hex, 'FFFF',
ST_NOT_UPDATED)
# ---- Ciphering keys and CB/SMS files ----
def build_kc(kc_hex, algo, size):
"""Kc: 9-byte USIM form (Kc + algo) or the 33-byte GSM record form."""
kc = bytes.fromhex(_norm_hex(kc_hex, 8))
return _pad_ff(kc + bytes([algo & 0xFF]), size).hex().upper()
def build_kc_invalidate(size):
"""Invalid Kc: 1-byte 07, USIM 9-byte FF*8+07, GSM 33-byte 07+FF*32."""
if size <= 1:
return '07'
if size == 9:
return (b'\xFF' * 8 + b'\x07').hex().upper()
return (b'\x07' + b'\xFF' * (size - 1)).hex().upper()
def build_smsstatus(count):
"""EF.SMSstatus: 2-byte big-endian counter (TS 51.011 10.5.9)."""
return '%04X' % (count & 0xFFFF)
def parse_smsstatus(data_hex):
"""Current counter value, or None when unreadable/erased."""
try:
data = bytes.fromhex(_norm_hex(data_hex))
except ValueError:
return None
if len(data) < 2 or all(b == 0xFF for b in data):
return None
return int.from_bytes(data[:2], 'big')
def bump_smsstatus(count):
value = parse_smsstatus(count)
return build_smsstatus((value + 1) if value is not None else 0x01FF)
def build_cbmi(ids, size=None):
"""CBMI: 2-byte message IDs; pad to the file size with FF when known."""
out = b''.join((i & 0xFFFF).to_bytes(2, 'big') for i in ids)
return _pad_ff(out, size).hex().upper()
def build_cbmir(ranges, size=None):
"""CBMIR: 4-byte ranges (low, high), most-significant byte first."""
out = b''.join((lo & 0xFFFF).to_bytes(2, 'big')
+ (hi & 0xFFFF).to_bytes(2, 'big') for lo, hi in ranges)
return _pad_ff(out, size).hex().upper()
# ---- Event Download and AUTHENTICATE ----
def build_location_status_event(status, plmn_hex=None, lac_hex=None,
cell_id_hex=None):
"""Location status data object (TS 102 223 8.27), with the optional
Location information object (8.19) only for normal service."""
out = bytes([0x9B, 0x01, status & 0xFF])
if (status == LOC_STATUS_NORMAL and plmn_hex and lac_hex
and cell_id_hex):
info = (_plmn_bytes(plmn_hex) + bytes.fromhex(_norm_hex(lac_hex, 2))
+ bytes.fromhex(_norm_hex(cell_id_hex, 2)))
out += bytes([0x13, len(info)]) + info
return out.hex().upper()
def build_auth_apdu(rand_hex, autn_hex, cla='00'):
"""AUTHENTICATE (3G/EPS/5G, P2 81), TS 31.102 7.1.2.1.
Command data: L1 RAND L2 AUTN = 1+16+1+16 = 34 bytes (Lc 0x22).
"""
rand = bytes.fromhex(_norm_hex(rand_hex, 16))
autn = bytes.fromhex(_norm_hex(autn_hex, 16))
body = bytes([len(rand)]) + rand + bytes([len(autn)]) + autn
return '%s880081%02X%s' % (cla, len(body), body.hex().upper())
def parse_auth_response(data_hex):
"""Parse the AUTHENTICATE response: DB (success) or DC (sync failure)."""
try:
data = bytes.fromhex(_norm_hex(data_hex or ''))
except ValueError:
return None
if not data or data[0] not in (0xDB, 0xDC):
return None
out = {'type': 'success' if data[0] == 0xDB else 'synchronisation_failure'}
i = 1
while i + 2 <= len(data):
ln = data[i]
out.setdefault('objects', []).append(data[i + 1:i + 1 + ln].hex().upper())
i += 1 + ln
return out
# ---- scenario runner ----
class StepError(Exception):
"""A write/select step failed; the scenario stops at that step."""
def _default(value, rng=None):
return value if value not in (None, '') else None
def _hexint(value, default=0):
"""Accept '0x1f'/1f/'31'/'3' style parameters as an integer."""
if value in (None, ''):
return default
if isinstance(value, int):
return value
text = str(value).strip()
try:
return int(text, 16) if re.fullmatch(r'(0[xX])?[0-9a-fA-F]+', text) else int(text)
except ValueError:
return default
class NetSimRunner:
"""Apply one scenario to a live card. The caller holds _CARD_LOCK."""
def __init__(self, srv, app, params=None, event_list=None, sleep=time.sleep):
self.srv = srv
self.app = app
self.params = params or {}
self.event_list = list(event_list or [])
self.sleep = sleep
self.lchan = app.rs.lchan[0]
self.steps = []
self._cleanups = []
scc = getattr(getattr(srv, '_server_ref', None), 'scc', None)
self.scc = scc
# -- parameter helpers
def p(self, key, default=None):
v = self.params.get(key)
return default if v in (None, '') else v
@property
def mcc(self):
return self.p('mcc', '001')
@property
def mnc(self):
return self.p('mnc', '01')
@property
def plmn(self):
return self.p('plmn') or plmn_bcd(self.mcc, self.mnc)
@property
def lac(self):
return self.p('lac') or rand_hex(2)
@property
def cell_id(self):
return self.p('cell_id') or rand_hex(2)
@property
def tac(self):
return self.p('tac') or self.lac
@property
def rac(self):
return self.p('rac') or rand_hex(1)
@property
def kasme(self):
return self.p('kasme') or rand_hex(32)
def status(self, default):
v = self.p('location_status')
return int(v, 16) if isinstance(v, str) else (int(v) if v is not None else default)
# -- step plumbing
def _add(self, action, **kw):
step = {'action': action}
step.update(kw)
self.steps.append(step)
return step
def _cleanup(self):
for c in self._cleanups:
try:
c()
except Exception:
pass
self._cleanups = []
def select(self, path):
cur, cleanup = self.srv._select_path(self.lchan, path, self.app)
if cleanup:
self._cleanups.append(cleanup)
return cur
def _open(self, key):
last = None
for path in FILE_PATHS[key]:
try:
self.select(path)
return path
except Exception as e:
last = e
raise StepError('no candidate file for %s (%s)' % (key, last))
def _meta(self):
l = self.lchan
return {'file_size': l.selected_file_size(),
'record_len': l.selected_file_record_len(),
'num_of_rec': l.selected_file_num_of_rec()}
def _check(self, data, sw, **kw):
ok = sw == '9000'
step = self._add(**kw, data=data, sw=sw, ok=ok)
if not ok:
raise StepError('%s returned SW %s' % (kw.get('action'), sw))
return step
def write_binary(self, key, data_hex, pad=True, label=None):
path = self._open(key)
size = self.lchan.selected_file_size()
data = data_hex
if pad and size:
data = _pad_ff(bytes.fromhex(data_hex), size).hex().upper()
_out, sw = self.lchan.update_binary(data)
return self._check(data, sw, action='update_binary', file=label or key,
path=path)
def write_record(self, key, data_hex, record=1, pad=True, label=None):
path = self._open(key)
size = self.lchan.selected_file_record_len()
data = data_hex
if pad and size:
data = _pad_ff(bytes.fromhex(data_hex), size).hex().upper()
_out, sw = self.lchan.update_record(record, data)
return self._check(data, sw, action='update_record', file=label or key,
path=path, record=record)
def read_binary_current(self):
data, sw = self.lchan.read_binary()
return (data or ''), sw
def read_record_current(self, record=1):
data, sw = self.lchan.read_record(record)
return (data or ''), sw
def send_location_status(self, status):
if EVENT_LOCATION_STATUS not in self.event_list:
self._add('skip', file='location_status',
note='event 0x03 not in SET UP EVENT LIST')
return
data = build_location_status_event(
status,
self.plmn if status == LOC_STATUS_NORMAL else None,
self.lac if status == LOC_STATUS_NORMAL else None,
self.cell_id if status == LOC_STATUS_NORMAL else None)
if not self.scc:
raise StepError('card session not available')
_d, sw = self.srv._send_event_download(self.scc, EVENT_LOCATION_STATUS,
bytes.fromhex(data))
self._add('event', file='location_status', data=data, sw=sw,
ok=(sw == '9000'))
# -- scenario building blocks
def invalidate_epsnsc(self, keep_key=True):
try:
self._open('epsnsc')
except StepError as e:
self._add('skip', file='epsnsc', note=str(e))
return
size = self.lchan.selected_file_record_len() or 54
kasme = None
if keep_key:
data, sw = self.read_record_current(1)
if sw == '9000':
kasme = parse_epsnsc_kasme(data)
self.write_record('epsnsc', build_epsnsc_invalidate(size, kasme),
label='epsnsc')
def store_epsnsc(self):
self.write_record('epsnsc', build_epsnsc(
_hexint(self.p('ksi', '03'), 0x03),
self.kasme,
_hexint(self.p('ul', 0)), _hexint(self.p('dl', 0)),
_hexint(self.p('algo', '02'), 0x02)),
label='epsnsc')
def write_real_locations(self, status=ST_UPDATED):
self.write_binary('loci', build_loci(
self.p('tmsi') or rand_hex(4), self.plmn, self.lac, status),
label='loci')
self.write_binary('psloci', build_psloci(
self.p('ptmsi') or rand_hex(4), self.p('ptmsi_sig') or rand_hex(3),
self.plmn, self.lac, self.rac, status), label='psloci')
self.write_binary('epsloci', build_epsloci(
self.p('guti') or rand_hex(12), self.plmn, self.tac, status),
label='epsloci')
def write_dummy_locations(self, status=ST_NOT_UPDATED):
self.write_binary('loci', build_loci_dummy(self.plmn), label='loci')
self.write_binary('psloci', build_psloci_dummy(self.plmn), label='psloci')
self.write_binary('epsloci', build_epsloci_dummy(self.plmn), label='epsloci')
def invalidate_kc(self):
for key in ('kc', 'kcgprs'):
try:
self._open(key)
except StepError:
continue
size = self.lchan.selected_file_size() or 9
self.write_binary(key, build_kc_invalidate(size), pad=False,
label=key)
def write_real_kc(self):
kc = self.p('kc') or rand_hex(8)
algo = int(self.p('kc_algo', 1))
for key in ('kc', 'kcgprs'):
try:
self._open(key)
except StepError:
continue
size = self.lchan.selected_file_size() or 9
self.write_binary(key, build_kc(kc, algo, size), pad=False,
label=key)
def bump_sms_counter(self):
try:
self._open('smsstatus')
except StepError as e:
self._add('skip', file='smsstatus', note=str(e))
return
data, sw = self.read_binary_current()
if sw != '9000':
data = ''
self.write_binary('smsstatus', bump_smsstatus(data), label='smsstatus')
# -- scenarios (UICC_NAA.md section 13)
def sc_cold_boot(self):
self.invalidate_epsnsc(keep_key=False)
if self.p('dummy_locations', True):
self.write_dummy_locations()
def sc_attach_eps(self):
self.store_epsnsc()
self.write_real_locations(ST_UPDATED)
def sc_service_lost(self):
if self.p('send_event', True):
self.send_location_status(self.status(LOC_STATUS_NO_SERVICE))
if self.p('invalidate_epsnsc', True):
self.invalidate_epsnsc(keep_key=bool(self.p('keep_kasme', True)))
if self.p('dummy_locations', True):
self.write_dummy_locations()
if self.p('write_kc', True):
self.invalidate_kc()
def sc_limited_service(self):
if self.p('send_event', True):
self.send_location_status(self.status(LOC_STATUS_LIMITED))
if self.p('invalidate_epsnsc', True):
self.invalidate_epsnsc(keep_key=bool(self.p('keep_kasme', True)))
if self.p('dummy_locations', True):
self.write_dummy_locations()
def sc_roaming_denied(self):
if self.p('send_event', True):
self.send_location_status(self.status(LOC_STATUS_LIMITED))
if self.p('invalidate_epsnsc', True):
self.invalidate_epsnsc(keep_key=bool(self.p('keep_kasme', True)))
if self.p('dummy_locations', True):
self.write_dummy_locations(status=self.status(ST_PLMN_NOT_ALLOWED))
def sc_churn(self):
count = int(self.p('churn_count', 3))
delay = int(self.p('churn_delay_ms', 150)) / 1000.0
real = build_epsnsc(
_hexint(self.p('ksi', '01'), 0x01),
self.kasme,
_hexint(self.p('ul', 0)), _hexint(self.p('dl', 0)),
_hexint(self.p('algo', '02'), 0x02))
self._open('epsnsc')
size = self.lchan.selected_file_record_len() or 54
invalid = build_epsnsc_invalidate(size, self.kasme)
for _i in range(max(1, count)):
self.write_record('epsnsc', real, label='epsnsc')
if delay:
self.sleep(delay)
self.write_record('epsnsc', invalid, label='epsnsc')
if delay:
self.sleep(delay)
def sc_attach_2g(self):
self.write_real_kc()
self.write_real_locations(ST_UPDATED)
def sc_sms_received(self):
self.bump_sms_counter()
if self.p('sms_location', True):
self.write_real_locations(ST_UPDATED)
def sc_cb_reconfig(self):
clear = bool(self.p('cb_clear', False))
try:
self._open('cbmi')
except StepError as e:
self._add('skip', file='cbmi', note=str(e))
clear = None
if clear is not None:
size = self.lchan.selected_file_size()
if clear:
self.write_binary('cbmi', 'FF' * (size or 20), pad=False,
label='cbmi')
else:
ids = self.p('cbmi_ids') or [0x111F, 0x1112]
self.write_binary('cbmi', build_cbmi(ids, size), pad=False,
label='cbmi')
try:
self._open('cbmir')
except StepError:
return
size = self.lchan.selected_file_size()
if clear:
self.write_binary('cbmir', 'FF' * (size or 40), pad=False,
label='cbmir')
else:
ranges = self.p('cbmir_ranges') or [[0x111F, 0x111F],
[0x1112, 0x1112]]
self.write_binary('cbmir', build_cbmir(ranges, size), pad=False,
label='cbmir')
def sc_authenticate(self):
rand = self.p('rand') or rand_hex(16)
autn = self.p('autn') or rand_hex(16)
if not self.scc:
raise StepError('card session not available')
apdu = build_auth_apdu(rand, autn)
data, sw = self.scc._tp.send_apdu(apdu)
if sw.startswith('61'):
data, sw = self.scc._tp.send_apdu('00C00000' + sw[2:4])
parsed = parse_auth_response(data)
self._add('authenticate', data=apdu, response=(data or '').upper(),
parsed=parsed, sw=sw, ok=True)
SCENARIOS = {
'cold_boot': sc_cold_boot,
'attach_eps': sc_attach_eps,
'service_lost': sc_service_lost,
'limited_service': sc_limited_service,
'roaming_denied': sc_roaming_denied,
'churn': sc_churn,
'attach_2g': sc_attach_2g,
'sms_received': sc_sms_received,
'cb_reconfig': sc_cb_reconfig,
'authenticate': sc_authenticate,
}
def run(self, scenario):
fn = self.SCENARIOS.get(scenario)
if not fn:
raise ValueError('unknown scenario %r' % scenario)
error = None
try:
fn(self)
except StepError as e:
error = str(e)
except Exception as e:
error = '%s: %s' % (type(e).__name__, e)
finally:
self._cleanup()
return {'scenario': scenario, 'success': error is None, 'error': error,
'steps': self.steps}
def run_scenario(srv, app, scenario, params=None, event_list=None,
sleep=time.sleep):
"""Run `scenario` against the equipped card; returns the step log."""
runner = NetSimRunner(srv, app, params=params, event_list=event_list,
sleep=sleep)
return runner.run(scenario)
+113 -1
View File
@@ -1,4 +1,5 @@
import json
import random
import sys
import os
import time
@@ -6,12 +7,14 @@ import threading
import traceback
import re
import codecs
from urllib.parse import unquote_plus
from datetime import datetime, timedelta
from http.server import HTTPServer, BaseHTTPRequestHandler
from io import StringIO
from pySim.transport import ApduTracer, ProactiveHandler
from pySim.cards import UiccCardBase
from pysim_simple_server import httpota
from pysim_simple_server import netsim
from pysim_simple_server import scp81
from smartcard.CardMonitoring import CardMonitor, CardObserver
@@ -21,7 +24,7 @@ from osmocom.construct import GsmOrUcs2Adapter
from osmocom.tlv import BER_TLV_IE
VERSION = '2.4.0'
VERSION = '2.5.0'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -435,6 +438,59 @@ def _read_iccid(app):
cleanup()
_MCC_MNC_CACHE = {'path': None, 'data': None}
def _mcc_mnc_load(path):
"""Load the optional MCC/MNC operator list (JSON) once; None if absent."""
if not path:
return None
if _MCC_MNC_CACHE['path'] == path:
return _MCC_MNC_CACHE['data']
data = None
try:
with open(path, 'r', encoding='utf-8') as f:
loaded = json.load(f)
if isinstance(loaded, list):
data = loaded
except Exception:
data = None
_MCC_MNC_CACHE['path'] = path
_MCC_MNC_CACHE['data'] = data
return data
def _mcc_mnc_search(data, query, limit=50):
"""Compact substring search over country/brand/operator/MCC/MNC."""
q = (query or '').strip().lower()
if not q:
return []
out = []
for e in data or []:
hay = ' '.join(str(e.get(k) or '') for k in
('countryName', 'countryCode', 'mcc', 'mnc', 'brand', 'operator')).lower()
if q not in hay:
continue
out.append({k: e.get(k) for k in
('countryName', 'countryCode', 'mcc', 'mnc', 'brand', 'operator', 'status')})
if len(out) >= limit:
break
return out
def _mcc_mnc_random(data, exclude=None):
"""One random operator entry (optionally excluding 'mccmnc' digits)."""
pool = [e for e in (data or [])
if (str(e.get('mcc') or '') + str(e.get('mnc') or '')) != (exclude or '')]
if not pool:
pool = data or []
if not pool:
return None
e = random.choice(pool)
return {k: e.get(k) for k in
('countryName', 'countryCode', 'mcc', 'mnc', 'brand', 'operator', 'status')}
def _parse_tree_output(output):
lines = (output or '').split('\n')
children = []
@@ -3137,6 +3193,32 @@ class PysimHandler(BaseHTTPRequestHandler):
self._log_req()
self._send_json({'version': VERSION})
self._log_resp({'version': VERSION})
elif self.path.startswith('/api/mcc-mnc'):
self._log_req()
q = ''
random_pick = False
exclude = ''
if '?' in self.path:
for kv in self.path.split('?', 1)[1].split('&'):
if kv.startswith('q='):
q = unquote_plus(kv[2:])
elif kv.startswith('random='):
random_pick = kv.split('=', 1)[1] not in ('0', 'false', '')
elif kv.startswith('exclude='):
exclude = unquote_plus(kv[8:])
path = getattr(self.server, 'mcc_mnc_path', None)
data = _mcc_mnc_load(path)
if data is None:
resp = {'available': False}
elif random_pick:
resp = {'available': True, 'count': len(data),
'result': _mcc_mnc_random(data, exclude)}
else:
results = _mcc_mnc_search(data, q)
resp = {'available': True, 'count': len(data), 'results': results}
self._send_json(resp)
self._log_resp({'available': resp['available'],
'results': len(resp.get('results', []))})
elif self.path == '/api/status':
self._log_req()
app = self.server.app
@@ -3374,6 +3456,36 @@ class PysimHandler(BaseHTTPRequestHandler):
_handle_card_disconnect()
self._send_json({'sw': None, 'error': 'card disconnected'})
self._log_resp({'sw': None, 'error': 'card disconnected'})
elif self.path == '/api/net-sim':
app = self.server.app
rs = app.rs if app else None
if not app or not rs:
self._send_json({'error': _err('no_card_state', lang)}, 503)
self._log_resp({'error': _err('no_card_state', lang)})
return
body = self._read_body()
self._log_req(body)
scenario = body.get('scenario')
if not scenario:
self._send_json({'error': 'scenario is required'}, 400)
self._log_resp({'error': 'scenario is required'})
return
try:
result = netsim.run_scenario(
sys.modules[__name__], app, scenario, body,
event_list=getattr(self.server, 'event_list', None) or [])
self._send_json(result)
self._log_resp({'scenario': scenario,
'success': result.get('success'),
'steps': len(result.get('steps', []))})
except ValueError as e:
self._send_json({'error': str(e)}, 400)
self._log_resp({'error': str(e)})
except Exception as e:
sys.stderr.write('Net-sim error: %s\n' % e)
_handle_card_disconnect()
self._send_json({'error': 'simulation failed: %s' % e}, 500)
self._log_resp({'error': str(e)})
elif self.path == '/api/rescue':
scc = self.server.scc
if not scc:
+315
View File
@@ -0,0 +1,315 @@
#!/usr/bin/env python3
"""Tests for the network-condition simulation (netsim.py).
Builders follow the trace study in `projects/UICC_NAA.md` (section 13):
EPS NAS security context, LOCI/PSLOCI/EPSLOCI real and dummy forms, Kc,
SMS-status counter, CBMI/CBMIR, Location status events and AUTHENTICATE.
The runner test drives a fake card so no hardware is needed.
"""
import sys
import unittest
from pathlib import Path
from types import SimpleNamespace
PROJECTS = Path(__file__).resolve().parents[2]
PY_SIM = PROJECTS / 'pysim'
if str(PY_SIM) not in sys.path:
sys.path.insert(0, str(PY_SIM))
from pysim_simple_server import netsim
class BuilderTests(unittest.TestCase):
def test_plmn_bcd_round_trip_with_known_vectors(self):
# pySim PLMNsel test vector: 228/06 -> 22 F8 60
self.assertEqual(netsim.plmn_bcd('228', '06'), '22F860')
# two-digit MNC pads the third nibble with F, three-digit does not
self.assertEqual(netsim.plmn_bcd('262', '01'), '62F210')
self.assertEqual(netsim.plmn_bcd('262', '001'), '621200')
with self.assertRaises(ValueError):
netsim.plmn_bcd('26', '01')
with self.assertRaises(ValueError):
netsim.plmn_bcd('262', '1')
def test_loci_real_and_dummy(self):
real = netsim.build_loci('3E905D6B', '52F002', '6CD7', 0x00)
self.assertEqual(len(real) // 2, 11)
self.assertEqual(real, '3E905D6B52F0026CD7FF00')
dummy = netsim.build_loci_dummy('52F002')
self.assertEqual(dummy, 'FFFFFFFF52F002FFFEFF01')
# PLMN kept, LAC FFFE, status "not updated"
def test_psloci_real_and_dummy(self):
real = netsim.build_psloci('F9236619', 'FFFFFF', '52F002', '6CD7', 'CA', 0x00)
self.assertEqual(len(real) // 2, 14)
self.assertEqual(real[-2:], '00')
dummy = netsim.build_psloci_dummy('52F002')
self.assertEqual(dummy, 'FFFFFFFFFFFFFF52F002FFFEFF01')
def test_epsloci_real_and_dummy(self):
real = netsim.build_epsloci('AB' * 12, '52F099', '8001', 0x00)
self.assertEqual(len(real) // 2, 18)
self.assertEqual(real[24:30], '52F099') # TAI PLMN after the 12-byte GUTI
self.assertEqual(real[-2:], '00')
dummy = netsim.build_epsloci_dummy('52F099')
self.assertEqual(len(dummy) // 2, 18)
self.assertTrue(dummy.startswith('0BF652F099'))
self.assertEqual(dummy[10:24], 'FF' * 7) # identity wiped
self.assertTrue(dummy.endswith('52F099FFFF01'))
def test_epsnsc_record_layout_and_padding(self):
rec = netsim.build_epsnsc(0x03, 'AB' * 32, 0x0E, 0x08, 0x02)
data = bytes.fromhex(rec)
self.assertEqual(len(data), 54)
self.assertEqual(data[:2], b'\xA0\x34')
self.assertEqual(data[2:5], b'\x80\x01\x03')
self.assertEqual(data[5:7], b'\x81\x20')
self.assertEqual(data[7:39], b'\xAB' * 32)
self.assertEqual(data[39:45], b'\x82\x04\x00\x00\x00\x0E')
self.assertEqual(data[45:51], b'\x83\x04\x00\x00\x00\x08')
self.assertEqual(data[51:54], b'\x84\x01\x02')
# card-specific record size 80 is padded with FF, no truncation
rec80 = netsim.build_epsnsc(0x04, 'CD' * 32, 1, 2, 1, size=80)
self.assertEqual(len(bytes.fromhex(rec80)), 80)
self.assertEqual(bytes.fromhex(rec80)[54:], b'\xFF' * 26)
def test_epsnsc_invalidate_and_keep_key(self):
wipe = netsim.build_epsnsc_invalidate(54)
data = bytes.fromhex(wipe)
self.assertEqual(data[2:5], b'\x80\x01\x07')
self.assertEqual(data[7:39], b'\xFF' * 32)
self.assertEqual(data[39:51],
b'\x82\x04\xFF\xFF\xFF\xFF\x83\x04\xFF\xFF\xFF\xFF')
keep = netsim.build_epsnsc_invalidate(54, 'AB' * 32)
self.assertEqual(bytes.fromhex(keep)[7:39], b'\xAB' * 32)
self.assertEqual(netsim.parse_epsnsc_kasme(keep), 'AB' * 32)
self.assertIsNone(netsim.parse_epsnsc_kasme(wipe))
def test_kc_forms(self):
self.assertEqual(netsim.build_kc('0123456789ABCDEF', 0x01, 9),
'0123456789ABCDEF01')
self.assertEqual(len(bytes.fromhex(netsim.build_kc('01' * 8, 1, 33))), 33)
self.assertEqual(netsim.build_kc_invalidate(1), '07')
self.assertEqual(netsim.build_kc_invalidate(9), 'FFFFFFFFFFFFFFFF07')
self.assertEqual(netsim.build_kc_invalidate(33), '07' + 'FF' * 32)
def test_smsstatus_counter(self):
self.assertEqual(netsim.build_smsstatus(0x27FF), '27FF')
self.assertEqual(netsim.parse_smsstatus('27FF'), 0x27FF)
self.assertEqual(netsim.bump_smsstatus('27FF'), '2800')
self.assertIsNone(netsim.parse_smsstatus('FFFF'))
self.assertEqual(netsim.bump_smsstatus('FFFF'), '01FF')
def test_cbmi_and_cbmir(self):
self.assertEqual(netsim.build_cbmi([0x111F, 0x1112], 20),
'111F1112' + 'FF' * 16)
self.assertEqual(netsim.build_cbmir([[0x111F, 0x111F]], 8),
'111F111FFFFFFFFF')
# FF-cleared list
self.assertEqual(netsim.build_cbmi([], 4), 'FFFFFFFF')
def test_location_status_event(self):
no_service = netsim.build_location_status_event(netsim.LOC_STATUS_NO_SERVICE)
self.assertEqual(no_service, '9B0102')
limited = netsim.build_location_status_event(netsim.LOC_STATUS_LIMITED)
self.assertEqual(limited, '9B0101')
# normal service may carry the Location information object (8.19)
normal = netsim.build_location_status_event(
netsim.LOC_STATUS_NORMAL, '52F099', '6CD7', '1234')
self.assertEqual(normal, '9B01001307' + '52F099' + '6CD7' + '1234')
def test_authenticate_apdu_and_response(self):
apdu = netsim.build_auth_apdu('11' * 16, '22' * 16)
self.assertTrue(apdu.startswith('0088008122'))
self.assertEqual(apdu[10:12], '10')
self.assertEqual(apdu[12:44], '11' * 16)
self.assertEqual(apdu[44:46], '10')
self.assertEqual(apdu[46:78], '22' * 16)
self.assertEqual(len(apdu) // 2, 39) # 5 header + 34 data
parsed = netsim.parse_auth_response('DB10' + 'AA' * 16 + '10' + 'BB' * 16)
self.assertEqual(parsed['type'], 'success')
self.assertEqual(parsed['objects'][0], 'AA' * 16)
sync = netsim.parse_auth_response('DC10' + 'CC' * 16)
self.assertEqual(sync['type'], 'synchronisation_failure')
self.assertEqual(sync['objects'], ['CC' * 16])
self.assertIsNone(netsim.parse_auth_response('9000'))
class FakeFileInfo:
def __init__(self, size=None, record_len=None, num=1, data=''):
self.size = size
self.record_len = record_len
self.num = num
self.data = data
class FakeLchan:
def __init__(self, files):
self.files = files
self.selected = None
self.writes = []
def select_by_path(self, path, app):
fid = path.split('/')[-1].upper()
if fid not in self.files:
raise RuntimeError('file not found: %s' % path)
self.selected = fid
return SimpleNamespace(fid=fid.lower()), None
def selected_file_size(self):
return self.files[self.selected].size
def selected_file_record_len(self):
return self.files[self.selected].record_len
def selected_file_num_of_rec(self):
return self.files[self.selected].num
def update_binary(self, data, offset=0):
self.writes.append(('binary', self.selected, data))
return data, '9000'
def update_record(self, rec, data):
self.writes.append(('record', self.selected, data))
return data, '9000'
def read_binary(self, length=None, offset=0):
return self.files[self.selected].data, '9000'
def read_record(self, rec):
return self.files[self.selected].data, '9000'
class FakeScc:
def __init__(self):
self.apdus = []
self._tp = SimpleNamespace(send_apdu=self._send)
def _send(self, apdu):
self.apdus.append(apdu)
if apdu.upper().startswith('00C0'): # the 61xx follow-up GET RESPONSE
return 'DB10' + 'AA' * 16, '9000'
return '', '6102'
class FakeSrv:
def __init__(self, scc=None):
self._server_ref = SimpleNamespace(scc=scc or FakeScc())
self.events = []
def _select_path(self, lchan, path, app):
return lchan.select_by_path(path, app)
def _send_event_download(self, scc, event_type, event_data=None):
self.events.append((event_type, bytes(event_data or b'').hex().upper()))
return b'', '9000'
FILES = {
'6FE4': FakeFileInfo(record_len=54, num=1, data=netsim.build_epsnsc(0x03, 'AB' * 32, 0, 0, 2)),
'6F7E': FakeFileInfo(size=11),
'6F73': FakeFileInfo(size=14),
'6FE3': FakeFileInfo(size=18),
'4F20': FakeFileInfo(size=9),
'4F52': FakeFileInfo(size=9),
'6F43': FakeFileInfo(size=2, data='27FF'),
'6F45': FakeFileInfo(size=20),
'6F50': FakeFileInfo(size=40),
}
def make_runner(params=None, event_list=(3,), sleep=None):
lchan = FakeLchan(dict(FILES))
app = SimpleNamespace(rs=SimpleNamespace(lchan=[lchan]))
srv = FakeSrv()
runner = netsim.NetSimRunner(srv, app, params=params, event_list=event_list,
sleep=sleep or (lambda s: None))
return runner, lchan, srv
class RunnerTests(unittest.TestCase):
def test_cold_boot_invalidates_and_dummies_locations(self):
runner, lchan, srv = make_runner()
out = runner.run('cold_boot')
self.assertTrue(out['success'])
writes = [(w[0], w[1]) for w in lchan.writes]
self.assertEqual(writes, [('record', '6FE4'), ('binary', '6F7E'),
('binary', '6F73'), ('binary', '6FE3')])
epsnsc = lchan.writes[0][2]
self.assertTrue(epsnsc.startswith('A0348001078120' + 'FF' * 32))
# dummy LOCI: TMSI FF, PLMN 001-01 (00 F1 10), LAC FFFE, status 01
self.assertEqual(lchan.writes[1][2], 'FFFFFFFF00F110FFFEFF01')
def test_service_lost_keeps_the_old_kasme_and_writes_dummies(self):
runner, lchan, srv = make_runner()
out = runner.run('service_lost')
self.assertTrue(out['success'])
self.assertEqual(srv.events[0][0], 3)
self.assertEqual(srv.events[0][1], '9B0102')
epsnsc = [w for w in lchan.writes if w[1] == '6FE4'][0][2]
self.assertTrue(epsnsc.startswith('A0348001078120' + 'AB' * 32))
keys = [w[1] for w in lchan.writes if w[0] == 'binary']
self.assertIn('6F7E', keys)
self.assertIn('6F73', keys)
self.assertIn('6FE3', keys)
self.assertIn('4F20', keys) # Kc invalidate (07 form)
kc = [w for w in lchan.writes if w[1] == '4F20'][0][2]
self.assertEqual(kc, 'FFFFFFFFFFFFFFFF07')
def test_event_step_skipped_when_not_subscribed(self):
runner, lchan, srv = make_runner(event_list=[])
out = runner.run('service_lost')
self.assertTrue(out['success'])
self.assertEqual(srv.events, [])
self.assertTrue(any(s.get('note') for s in out['steps']))
def test_attach_eps_stores_context_and_real_locations(self):
runner, lchan, srv = make_runner({'ksi': '04', 'kasme': 'CD' * 32,
'ul': 1, 'dl': 2, 'algo': '02'})
out = runner.run('attach_eps')
self.assertTrue(out['success'])
epsnsc = [w for w in lchan.writes if w[1] == '6FE4'][0][2]
self.assertTrue(epsnsc.startswith('A0348001048120' + 'CD' * 32))
loci = [w for w in lchan.writes if w[1] == '6F7E'][0][2]
self.assertTrue(loci.endswith('00'))
self.assertEqual(len(loci) // 2, 11)
def test_sms_received_bumps_the_counter(self):
runner, lchan, srv = make_runner()
out = runner.run('sms_received')
self.assertTrue(out['success'])
sms = [w for w in lchan.writes if w[1] == '6F43'][0][2]
self.assertEqual(sms, '2800')
def test_churn_alternates_real_and_invalid_with_delays(self):
delays = []
runner, lchan, srv = make_runner({'churn_count': 2, 'churn_delay_ms': 10},
sleep=lambda s: delays.append(s))
out = runner.run('churn')
self.assertTrue(out['success'])
recs = [w[2] for w in lchan.writes if w[1] == '6FE4']
self.assertEqual(len(recs), 4)
self.assertTrue(recs[0].startswith('A0348001018120'))
self.assertTrue(recs[1].startswith('A0348001078120'))
self.assertEqual(len(delays), 4)
def test_authenticate_sends_apdu_and_parses_response(self):
runner, lchan, srv = make_runner()
out = runner.run('authenticate')
self.assertTrue(out['success'])
apdus = srv._server_ref.scc.apdus
self.assertTrue(apdus[0].startswith('0088008122'))
self.assertTrue(apdus[1].startswith('00C00000'))
step = [s for s in out['steps'] if s['action'] == 'authenticate'][0]
self.assertEqual(step['sw'], '9000')
self.assertEqual(step['parsed']['type'], 'success')
def test_unknown_scenario_raises(self):
runner, _lchan, _srv = make_runner()
with self.assertRaises(ValueError):
runner.run('nope')
if __name__ == '__main__':
unittest.main()