From f8ba5dea2c1375872ef549a9ebe19768ca935237 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Mon, 21 Sep 2026 22:13:52 +0300 Subject: [PATCH] fix: use the card's own SimCardCommands for ADM and AUTHENTICATE (v2.7.20) /api/verify-adm sent VERIFY with CLA A0 on a UICC (SW 6E00) while pySim-shell's verify_adm worked: fast init builds the card on its own SimCardCommands instance, but __main__ kept server.scc at the startup placeholder left at the SIM defaults; only an equip repointed it. - __main__ adopts card._scc after init (cat_cla still set on it), so server.scc carries the card's cla_byte/sel_ctrl from startup on. - _verify_adm prefers app.rs.lchan[0].scc / app.card._scc, exactly like pySim-shell's verify_adm, independent of server.scc. - netsim AUTHENTICATE follows the card class: a UICC gets 00 88 00 81 22 (RAND+AUTN, DB/DC response), a SIM gets A0 88 00 00 10 (RAND only, SRES+Kc); the 61xx GET RESPONSE uses the same CLA. - tests: ADM with a stale placeholder scc; 2G builder/parser; SIM-CLA runner case; help EN/RU and AGENTS updated. --- frontend/help-ru.html | 2 +- frontend/help.html | 2 +- frontend/index.html | 2 +- frontend/sw.js | 2 +- pyproject.toml | 2 +- pysim_simple_server/__main__.py | 5 +++++ pysim_simple_server/netsim.py | 33 +++++++++++++++++++++++++++------ pysim_simple_server/server.py | 11 ++++++++++- tests/test_adm_verify.py | 17 +++++++++++++++++ tests/test_netsim.py | 28 ++++++++++++++++++++++++++++ 10 files changed, 92 insertions(+), 12 deletions(-) diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 92b031f..ae0693f 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -487,7 +487,7 @@
  • Серия переподключений — реальная → невалидная запись EPSNSC подряд (число циклов и задержка настраиваются).
  • Принято SMS — инкремент счётчика EF.SMSstatus (чтение-изменение-запись) и, по желанию, перезапись location-файлов.
  • Перенастройка CB — запись списков CBMI/CBMIR или их очистка (все FF).
  • -
  • AUTHENTICATE — команда AUTHENTICATE (3G/EPS/5G, 00 88 00 81 22) с заданными или случайными RAND/AUTN и показ ответа (успех DB или ошибка синхронизации DC с AUTS).
  • +
  • AUTHENTICATE — команда AUTHENTICATE с заданными или случайными RAND/AUTN и показ ответа. Команда зависит от класса карты: UICC получает форму 3G/EPS/5G (00 88 00 81 22, ответ DB — успех или DC — ошибка синхронизации с AUTS), SIM — форму 2G (A0 88 00 00 10, только RAND, ответ SRES + Kc).
  • Параметры (свёрнуты) задают оператора (поиск по мировому списку MCC/MNC с сервера плюс выбор случайного роуминг-оператора и кнопка «Домашняя сеть», заполняющая HPLMN карты из первой записи EF.HPLMNwAcT с откатом на IMSI), LAC/Cell ID/TAC/RAC, необязательные идентификаторы (пусто = случайно: TMSI, GUTI, KSI, KASME, Kc, счётчики NAS, алгоритм, RAND/AUTN), переключатели сценария (включая «Отказ: записать FPLMN») и число циклов/задержку. Журнал шагов показывает каждую запись, ENVELOPE и AUTHENTICATE с их SW. Отправляются только UPDATE BINARY/RECORD, ENVELOPE и AUTHENTICATE — FPLMN дописывается только сценарием постоянного отказа (TS 31.102 §4.2.16, без дубликатов), а подключение к сети из списка сначала очищает её запись (успешный ручной выбор, TS 23.122); 5GS location-файлы не записываются; записи меняют карту и видны в последующих сравнениях снимков.

    diff --git a/frontend/help.html b/frontend/help.html index 82c05bb..da68092 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -487,7 +487,7 @@
  • Churn — replay real → invalid EPSNSC records back-to-back (count and delay configurable).
  • SMS received — bump the EF.SMSstatus counter (read-modify-write) and optionally rewrite the location files.
  • CB reconfig — write the CBMI/CBMIR message-ID lists or clear them (all FF).
  • -
  • AUTHENTICATE — send AUTHENTICATE (3G/EPS/5G, 00 88 00 81 22) with the given or random RAND/AUTN and show the response (success DB or synchronisation failure DC with AUTS).
  • +
  • AUTHENTICATE — send AUTHENTICATE with the given or random RAND/AUTN and show the response. The command follows the card class: a UICC gets the 3G/EPS/5G form (00 88 00 81 22, response DB success or DC synchronisation failure with AUTS), a SIM the 2G form (A0 88 00 00 10, RAND only, response SRES + Kc).
  • Parameters (collapsed) provide the operator (searchable worldwide MCC/MNC list served from the server, plus a random roaming picker and a Home network button that fills the card’s HPLMN from EF.HPLMNwAcT’s first record, falling back to the IMSI), LAC/Cell ID/TAC/RAC, optional identity values (empty = random: TMSI, GUTI, KSI, KASME, Kc, NAS counts, algorithm, RAND/AUTN), the scenario toggles (including Rejection: write FPLMN) 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 is appended only by the permanent rejection scenario (TS 31.102 §4.2.16, never duplicated) and an attach to a listed PLMN clears its entry first (successful manual selection, TS 23.122), and the 5GS location files are never written; the writes change the card and are visible to later snapshot comparisons.

    diff --git a/frontend/index.html b/frontend/index.html index 874cddf..795fc35 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1414,7 +1414,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.7.19'; +const SIMPLE_VERSION = '2.7.20'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== Tab switching ===== diff --git a/frontend/sw.js b/frontend/sw.js index 384f42e..3cbc0d0 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v222'; +const CACHE = 'simple-v223'; const URLS = [ 'index.html', 'help.html', diff --git a/pyproject.toml b/pyproject.toml index 6789266..d7ed51c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "2.7.19" +version = "2.7.20" 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. diff --git a/pysim_simple_server/__main__.py b/pysim_simple_server/__main__.py index 04fbc89..912b5eb 100644 --- a/pysim_simple_server/__main__.py +++ b/pysim_simple_server/__main__.py @@ -138,6 +138,11 @@ def main(): sys.stderr.write('INIT: no card in the reader — server ready; insert a card or press Equip\n') _tlog('card_init: %.0fms' % ((time.time() - t_phase) * 1000)) if card is not None: + # Fast init builds the card on its own SimCardCommands instance; + # adopt it so server.scc carries the card's cla_byte/sel_ctrl (the + # placeholder created above is a different object left at the SIM + # defaults, which made VERIFY go out with CLA A0 on a UICC). + scc = card._scc scc.cat_cla = '80' if isinstance(card, UiccCardBase) else 'a0' except Exception: print("Warning: reader/card initialization failed:", file=sys.stderr) diff --git a/pysim_simple_server/netsim.py b/pysim_simple_server/netsim.py index 6712014..c7377fc 100644 --- a/pysim_simple_server/netsim.py +++ b/pysim_simple_server/netsim.py @@ -336,15 +336,30 @@ def build_auth_apdu(rand_hex, autn_hex, cla='00'): 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()) + return '%s880081%02X%s' % (cla.upper(), len(body), body.hex().upper()) -def parse_auth_response(data_hex): - """Parse the AUTHENTICATE response: DB (success) or DC (sync failure).""" +def build_auth_apdu_gsm(rand_hex, cla='a0'): + """2G AUTHENTICATE (GSM 11.11 8.8): P1/P2 00, Lc 0x10, RAND only. + + The response is SRES (4 B) followed by Kc (8 B). + """ + rand = bytes.fromhex(_norm_hex(rand_hex, 16)) + return '%s88000010%s' % (cla.upper(), rand.hex().upper()) + + +def parse_auth_response(data_hex, gsm=False): + """Parse the AUTHENTICATE response: DB (success) or DC (sync failure) for + 3G/EPS/5G, SRES||Kc for the 2G SIM command.""" try: data = bytes.fromhex(_norm_hex(data_hex or '')) except ValueError: return None + if gsm: + if len(data) < 12: + return None + return {'type': 'gsm', 'sres': data[0:4].hex().upper(), + 'kc': data[4:12].hex().upper()} if not data or data[0] not in (0xDB, 0xDC): return None out = {'type': 'success' if data[0] == 0xDB else 'synchronisation_failure'} @@ -815,11 +830,17 @@ class NetSimRunner: autn = self.p('autn') or rand_hex(16) if not self.scc: raise StepError('card session not available') - apdu = build_auth_apdu(rand, autn) + # The command follows the card's class: a UICC gets the 3G/EPS/5G + # AUTHENTICATE (P2 81, RAND + AUTN), a SIM the 2G one (P1/P2 00, + # RAND only, SRES + Kc in the response). + cla = (getattr(self.scc, 'cla_byte', None) or '00').upper() + gsm = cla == 'A0' + apdu = (build_auth_apdu_gsm(rand, cla) if gsm + else build_auth_apdu(rand, autn, cla)) 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) + data, sw = self.scc._tp.send_apdu(cla + 'C00000' + sw[2:4]) + parsed = parse_auth_response(data, gsm=gsm) self._add('authenticate', data=apdu, response=(data or '').upper(), parsed=parsed, sw=sw, ok=True) diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index 1217e77..d3f03d1 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -26,7 +26,7 @@ from osmocom.tlv import BER_TLV_IE from osmocom.utils import rpad -VERSION = '2.7.19' +VERSION = '2.7.20' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -2698,6 +2698,15 @@ def _verify_adm(scc, app, adm_hex): remaining attempts: every failed VERIFY consumes one, and a blocked ADM cannot be recovered from here (it needs the unblock key). """ + # Use the card's own logical channel, exactly like pySim-shell's + # verify_adm. server.scc can still be the startup placeholder (SIM CLA) + # when no equip has happened yet, and a UICC then answers 6E00. + rs = getattr(app, 'rs', None) + lchan = rs.lchan[0] if rs is not None and getattr(rs, 'lchan', None) else None + card_scc = (getattr(lchan, 'scc', None) + or getattr(getattr(app, 'card', None), '_scc', None)) + if card_scc is not None: + scc = card_scc chv = getattr(getattr(app, 'card', None), '_adm_chv_num', 0x0A) fc = rpad(str(adm_hex).lower(), 16) _data, sw = scc.send_apdu(scc.cla_byte + '2000' + ('%02X' % chv) + '08' + fc) diff --git a/tests/test_adm_verify.py b/tests/test_adm_verify.py index 76b6533..f93fab9 100644 --- a/tests/test_adm_verify.py +++ b/tests/test_adm_verify.py @@ -77,6 +77,23 @@ class AdmVerifyTests(unittest.TestCase): self.assertEqual(out, {'adm': '', 'psk_hex': '', 'other': 'x'}) self.assertEqual(server._redact_psk_fields({'adm': ''}), {'adm': ''}) + def test_verify_adm_uses_the_cards_own_channel(self): + # server.scc can be the startup placeholder left at the SIM CLA ('a0') + # while the card is a UICC: the VERIFY must go out through the card's + # own channel (CLA 00), exactly like pySim-shell's verify_adm. + stale = FakeScc('6E00') + stale.cla_byte = 'a0' + card_scc = FakeScc('9000') + card_scc.cla_byte = '00' + app = SimpleNamespace( + card=SimpleNamespace(_adm_chv_num=0x0A, _scc=card_scc), + rs=SimpleNamespace(adm_verified=False, + lchan=[SimpleNamespace(scc=card_scc)])) + res = server._verify_adm(stale, app, '0011') + self.assertEqual(res, {'ok': True, 'sw': '9000'}) + self.assertEqual(card_scc.apdus, ['0020000A08' + '0011' + 'f' * 12]) + self.assertEqual(stale.apdus, []) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_netsim.py b/tests/test_netsim.py index 3ed93b0..208587c 100644 --- a/tests/test_netsim.py +++ b/tests/test_netsim.py @@ -191,6 +191,17 @@ class BuilderTests(unittest.TestCase): self.assertEqual(sync['objects'], ['CC' * 16]) self.assertIsNone(netsim.parse_auth_response('9000')) + def test_authenticate_2g_apdu_and_response(self): + # SIM cards use the 2G AUTHENTICATE: P1/P2 00, RAND only + self.assertEqual(netsim.build_auth_apdu_gsm('11' * 16), + 'A088000010' + '11' * 16) + parsed = netsim.parse_auth_response('AABBCCDD' + '1122334455667788', + gsm=True) + self.assertEqual(parsed, {'type': 'gsm', 'sres': 'AABBCCDD', + 'kc': '1122334455667788'}) + # a truncated 2G response is not parsed + self.assertIsNone(netsim.parse_auth_response('AABBCCDD', gsm=True)) + class FakeFileInfo: def __init__(self, size=None, record_len=None, num=1, data=''): @@ -430,6 +441,23 @@ class RunnerTests(unittest.TestCase): self.assertEqual(step['sw'], '9000') self.assertEqual(step['parsed']['type'], 'success') + def test_authenticate_uses_the_2g_command_for_a_sim_cla(self): + runner, lchan, srv = make_runner() + scc = srv._server_ref.scc + scc.cla_byte = 'a0' # a SIM card session + sent = [] + def _send(apdu): + sent.append(apdu) + return 'AABBCCDD' + '11' * 8, '9000' + scc._tp.send_apdu = _send + out = runner.run('authenticate') + self.assertTrue(out['success']) + self.assertTrue(sent[0].startswith('A088000010'), sent[0]) + step = [s for s in out['steps'] if s['action'] == 'authenticate'][0] + self.assertEqual(step['sw'], '9000') + self.assertEqual(step['parsed'], + {'type': 'gsm', 'sres': 'AABBCCDD', 'kc': '11' * 8}) + def test_unknown_scenario_raises(self): runner, _lchan, _srv = make_runner() with self.assertRaises(ValueError):