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:
@@ -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)
|
||||
Reference in New Issue
Block a user