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. 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 ## SCP81
The **SCP81** tab drives HTTP OTA (GP RAM over HTTP, GPC v2.2 Amendment B) and has two pills: **Listener** and **Scripts**. 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 на сервер. Значения сохраняются на сервере до перезапуска. 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
Вкладка **SCP81** управляет HTTP OTA (GP RAM over HTTP, GPC v2.2 Amendment B) и содержит две подвкладки: **Listener** и **Scripts**. Вкладка **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/stk-status` | GET | STK session state (active/pending/type) |
| `/api/events` | GET | Event list from SET UP EVENT LIST | | `/api/events` | GET | Event list from SET UP EVENT LIST |
| `/api/event-send` | POST | Send ENVELOPE(Event Download) | | `/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/proactive-log` | GET | Last 50 proactive commands |
| `/api/status-poll` | POST | Manual STATUS poll + FETCH if 91XX | | `/api/status-poll` | POST | Manual STATUS poll + FETCH if 91XX |
| `/api/rescue` | POST | Re-send TERMINAL PROFILE to recover CAT session | | `/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 type, ATR, ICCID and other information pySim reports for the equipped
card). A shortcut for `POST /api/command` with `{"cmd": "cardinfo"}`. 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` ### `POST /api/command`
Execute any pysim-shell 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. 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 ```json
{"event_type": 4, "event_data": "01A0"} {"event_type": 4, "event_data": "01A0"}
``` ```
+13
View File
@@ -474,6 +474,19 @@
</ul> </ul>
<p class="text-sm mb-3">Значения хранятся на сервере до перезапуска. Когда карта выдаёт PLI, сервер вставляет значения словаря в TERMINAL RESPONSE.</p> <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>
<section class="mb-10"> <section class="mb-10">
+13
View File
@@ -474,6 +474,19 @@
</ul> </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> <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>
<section class="mb-10"> <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> <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> <div id="pysim-proactive-log" class="text-sm font-mono text-gray-600 dark:text-slate-400"></div>
</fieldset> </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>
<div id="phone-sub-tr" class="hidden"> <div id="phone-sub-tr" class="hidden">
<div class="flex justify-between items-center mb-2"> <div class="flex justify-between items-center mb-2">
@@ -1301,7 +1388,7 @@
// ===== Version ===== // ===== Version =====
// Single source of truth for the PWA version: shown in the header and used // Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect(). // 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; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching ===== // ===== Tab switching =====
@@ -12231,6 +12318,160 @@ function efContentDiff(name, fid, expectedHex, actualHex) {
return { rows: rows, sameDecoded: rows.length === 0 }; 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 ===== // ===== i18n =====
const LANG_RU = { const LANG_RU = {
'Generate APDU': 'Сгенерировать APDU', 'Generate APDU': 'Сгенерировать APDU',
@@ -12488,6 +12729,38 @@ const LANG_RU = {
'Events that the card monitors (SET UP EVENT LIST):': 'События, отслеживаемые картой (SET UP EVENT LIST):', 'Events that the card monitors (SET UP EVENT LIST):': 'События, отслеживаемые картой (SET UP EVENT LIST):',
'No events configured.': 'Нет настроенных событий.', 'No events configured.': 'Нет настроенных событий.',
'Fetched proactive commands:': 'Извлечённые проактивные команды:', '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.': 'Команды пока не регистрировались.', 'No commands logged yet.': 'Команды пока не регистрировались.',
'Send event notification to the card?': 'Отправить уведомление о событии на карту?', 'Send event notification to the card?': 'Отправить уведомление о событии на карту?',
'This event type is not yet supported.': 'Этот тип события пока не поддерживается.', 'This event type is not yet supported.': 'Этот тип события пока не поддерживается.',
+22
View File
@@ -823,6 +823,10 @@ video {
max-height: 14rem; max-height: 14rem;
} }
.max-h-\[30vh\] {
max-height: 30vh;
}
.max-h-\[35vh\] { .max-h-\[35vh\] {
max-height: 35vh; max-height: 35vh;
} }
@@ -919,6 +923,10 @@ video {
width: 1.5rem; width: 1.5rem;
} }
.w-64 {
width: 16rem;
}
.w-72 { .w-72 {
width: 18rem; width: 18rem;
} }
@@ -940,6 +948,10 @@ video {
min-width: 0px; min-width: 0px;
} }
.min-w-\[14rem\] {
min-width: 14rem;
}
.min-w-\[180px\] { .min-w-\[180px\] {
min-width: 180px; min-width: 180px;
} }
@@ -1265,6 +1277,11 @@ video {
border-top-color: transparent; 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 { .bg-black\/50 {
background-color: rgb(0 0 0 / 0.5); background-color: rgb(0 0 0 / 0.5);
} }
@@ -1693,6 +1710,11 @@ video {
color: #f1f5f9; 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 { .hover\:bg-blue-700:hover {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--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 = [ const URLS = [
'index.html', 'index.html',
'help.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'")); 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', () => { test('SCP81 listener exposes its four modes with the matching notes', () => {
for (const v of ['tls', 'redirect', 'passthru', 'dump']) { for (const v of ['tls', 'redirect', 'passthru', 'dump']) {
assert.ok(html.includes('value="' + v + '"'), v); 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] [project]
name = "pysim-simple-server" name = "pysim-simple-server"
version = "2.4.0" version = "2.5.0"
description = "HTTP REST server wrapping pysim for the SIMple PWA" description = "HTTP REST server wrapping pysim for the SIMple PWA"
requires-python = ">=3.8" requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. # 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') 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(): def main():
global _server_start global _server_start
_server_start = time.time() _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('--http-port', type=int, default=8080, help='Bind port (default: 8080)')
parser.add_argument('--web-dir', default=_default_web_dir(), metavar='PATH', parser.add_argument('--web-dir', default=_default_web_dir(), metavar='PATH',
help='Directory with the SIMple PWA static files to serve (default: <repo>/frontend)') 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('--log-requests', action='store_true', default=False, help='Log request/response payloads to stderr')
parser.add_argument('--sms-oa', default='12345', metavar='DIGITS', parser.add_argument('--sms-oa', default='12345', metavar='DIGITS',
help='TP-Originating-Address (SMSC number) for the SMS-DELIVER TPDU (default: 12345)') 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.terminal_profile = opts.terminal_profile
server.cli_terminal_profile = opts.terminal_profile server.cli_terminal_profile = opts.terminal_profile
server.web_dir = opts.web_dir server.web_dir = opts.web_dir
server.mcc_mnc_path = opts.mcc_mnc_list
server.sim_menu = sim_menu server.sim_menu = sim_menu
server.event_list = event_list server.event_list = event_list
server.menu_active = False 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 json
import random
import sys import sys
import os import os
import time import time
@@ -6,12 +7,14 @@ import threading
import traceback import traceback
import re import re
import codecs import codecs
from urllib.parse import unquote_plus
from datetime import datetime, timedelta from datetime import datetime, timedelta
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
from io import StringIO from io import StringIO
from pySim.transport import ApduTracer, ProactiveHandler from pySim.transport import ApduTracer, ProactiveHandler
from pySim.cards import UiccCardBase from pySim.cards import UiccCardBase
from pysim_simple_server import httpota from pysim_simple_server import httpota
from pysim_simple_server import netsim
from pysim_simple_server import scp81 from pysim_simple_server import scp81
from smartcard.CardMonitoring import CardMonitor, CardObserver from smartcard.CardMonitoring import CardMonitor, CardObserver
@@ -21,7 +24,7 @@ from osmocom.construct import GsmOrUcs2Adapter
from osmocom.tlv import BER_TLV_IE 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 MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -435,6 +438,59 @@ def _read_iccid(app):
cleanup() 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): def _parse_tree_output(output):
lines = (output or '').split('\n') lines = (output or '').split('\n')
children = [] children = []
@@ -3137,6 +3193,32 @@ class PysimHandler(BaseHTTPRequestHandler):
self._log_req() self._log_req()
self._send_json({'version': VERSION}) self._send_json({'version': VERSION})
self._log_resp({'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': elif self.path == '/api/status':
self._log_req() self._log_req()
app = self.server.app app = self.server.app
@@ -3374,6 +3456,36 @@ class PysimHandler(BaseHTTPRequestHandler):
_handle_card_disconnect() _handle_card_disconnect()
self._send_json({'sw': None, 'error': 'card disconnected'}) self._send_json({'sw': None, 'error': 'card disconnected'})
self._log_resp({'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': elif self.path == '/api/rescue':
scc = self.server.scc scc = self.server.scc
if not 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()