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:
@@ -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**.
|
||||
|
||||
@@ -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
@@ -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"}
|
||||
```
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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.': 'Этот тип события пока не поддерживается.',
|
||||
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simple-v192';
|
||||
const CACHE = 'simple-v193';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user