Files
simple/tests/test_ota_helpers.py
T
catarrh 337e5df770 feat: decode the Items Next Action Indicator in proactive commands (v3.5.3)
SET UP MENU / SELECT ITEM items carry an optional Items Next Action Indicator
(TS 102 223 8.24, tag '18'): one byte per item, in list order, coded with the
Table 9.4 values marked "Used for Next Action Indicator".  The decoder ignored
it, so neither the proactive log nor the STK menu showed what the card would
do when an item is selected.

- server: NAI_TYPES whitelist (the ToC-only values - e.g. '26', '27', '47' -
  are reserved there and are ignored, as are '00' and unlisted values),
  _nai_name(), _attach_nai() (a short NAI list leaves the tail without an
  indicator, extra bytes are ignored); _parse_select_item() and
  _parse_setup_menu_items() plus the TERMINAL PROFILE SET UP MENU walk attach
  nai/nai_name to the items; _decode_cmd() renders '1. Menu -> SET UP MENU'.
- PWA: stkMenuNaiSuffix() adds a small gray '<name>' suffix to STK menu items
  that carry an NAI; the proactive log picks the decode up via cmd_decoded.
- tests: SET UP MENU / SELECT ITEM vectors with NAIs, a reserved value that is
  ignored, a short NAI list, and the stkMenuNaiSuffix unit test.
- docs: UICC_SPECS 6.5 gained the '18' Items next action indicator row
  (8.24/9.4); help EN/RU mention the menu suffix; AGENTS files updated.

548 frontend / 413 Python green; version 3.5.3; sw cache simple-v255.
2026-09-24 20:37:53 +03:00

1246 lines
56 KiB
Python

#!/usr/bin/env python3
"""Unit tests for the OTA helper functions in pysim_simple_server.server.
Reference vectors are key-free: synthetic dummy keys plus the already-public
sysmocom sample-key vectors that ship in pySim's own tests/unittests/test_ota.py.
No live/sample card keys and no ICCIDs appear here.
"""
import sys
import types
import unittest
from pathlib import Path
from unittest import mock
# pySim checkout is a sibling of this repo; put it on sys.path so the server
# module (which imports pySim at module level) can be exercised against it.
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.server import (
_build_secured_packet,
_build_sms_tpdu,
_build_tr,
_decode_cmd,
_decode_por,
_decode_tr,
_log_proactive,
_ota_reference,
_parse_select_item,
_parse_setup_menu_items,
_record_tr,
_send_secured_packet,
_spi_from_bytes,
_split_secured_packet,
_tr_data_only,
SCP80_FIRST_BYTES,
SCP80_MAX_SEGMENTS,
SCP80_NEXT_BYTES,
SCP80_SINGLE_BYTES,
)
# Synthetic dummy key material (no real card keys).
K = '00112233445566778899AABBCCDDEEFF'
# Public sysmocom sample keys from pySim tests/unittests/test_ota.py.
KIC3 = 'C21DD66ACAC13CB3BC8B331B24AFB57B'
KID3 = '12110C78E678C25408233076AA033615'
# Public synthetic AES keys from pySim tests/unittests/test_ota.py.
KIC_AES = '200102030405060708090a0b0c0d0e0f'
KID_AES = '201102030405060708090a0b0c0d0e0f'
APDU = '00a40000023f00'
# (spi1, spi2) -> expected secured packet, generated with _ota_reference
# against pySim's OtaDialectSms.encode_cmd and cross-checked with the JS genSp().
REFERENCE_VECTORS = {
('06', '09'):
'00201506091515b00000c08f58c38860acb3a362fffe670ad13759a2a6b4c1a91116',
('16', '01'):
'00201516011515b00000e42573469e68a8462a57a505b0e2b1c09c1928c7a182311f',
('02', '09'):
'001d1502091515b0000000000000010085a8ca1a9828b0bb00a40000023f00',
('01', '09'):
'00191101091515b0000000000000010050c942dc00a40000023f00',
}
# AES-128 reference vectors (public synthetic keys from pySim test_ota.py).
AES_APDU = '00a40004023f00'
AES_REFERENCE_VECTORS = {
('16', '19'):
'00281516192222b000115a47655527e96e832f1a5c698655715d4331454a0d83952c0ed35245706976b1',
('12', '09'):
'001d1512092222b0001100000000110029826122c7a0b79500a40004023f00',
('1e', '19'):
'0028151e192222b0001118b202ee47a3203e7370861c383b4142e704157b36e5c0eb4bb33eb6036cbaf8',
}
class TestSpiFromBytes(unittest.TestCase):
def test_06_09_ciphered_cc(self):
spi = _spi_from_bytes(0x06, 0x09)
self.assertEqual(spi, {
'counter': 'no_counter',
'ciphering': True,
'rc_cc_ds': 'cc',
'por_in_submit': False,
'por_shall_be_ciphered': False,
'por_rc_cc_ds': 'cc',
'por': 'por_required',
})
def test_16_01_counter_must_be_higher(self):
spi = _spi_from_bytes(0x16, 0x01)
self.assertEqual(spi['counter'], 'counter_must_be_higher')
self.assertTrue(spi['ciphering'])
self.assertEqual(spi['rc_cc_ds'], 'cc')
self.assertEqual(spi['por_rc_cc_ds'], 'no_rc_cc_ds')
def test_02_09_unciphered_cc(self):
spi = _spi_from_bytes(0x02, 0x09)
self.assertFalse(spi['ciphering'])
self.assertEqual(spi['rc_cc_ds'], 'cc')
self.assertEqual(spi['por_rc_cc_ds'], 'cc')
def test_04_19_ciphered_no_cc(self):
spi = _spi_from_bytes(0x04, 0x19)
self.assertTrue(spi['ciphering'])
self.assertEqual(spi['rc_cc_ds'], 'no_rc_cc_ds')
self.assertTrue(spi['por_shall_be_ciphered'])
self.assertEqual(spi['por_rc_cc_ds'], 'cc')
class TestBuildSmsTpdu(unittest.TestCase):
CHUNK = '00201506091515b00000c08f58c38860acb3a362fffe670ad13759a2a6b4c1a91116'
SCTS = bytes.fromhex('24051215173000')
def _build(self, *args, **kwargs):
with mock.patch('pysim_simple_server.server._encode_scts', return_value=self.SCTS):
return _build_sms_tpdu(*args, **kwargs)
def test_single_message_with_cpi(self):
self.assertEqual(
self._build(self.CHUNK, include_cpi=True),
'4005812143f57ff6240512151730002502700000201506091515b00000'
'c08f58c38860acb3a362fffe670ad13759a2a6b4c1a91116')
def test_single_message_without_cpi(self):
self.assertEqual(
self._build(self.CHUNK, include_cpi=False),
'0405812143f57ff6240512151730002200201506091515b00000'
'c08f58c38860acb3a362fffe670ad13759a2a6b4c1a91116')
def test_first_chunk_has_cpi(self):
self.assertEqual(
self._build(self.CHUNK, chunk_total=3, chunk_num=1, include_cpi=True),
'4405812143f57ff6240512151730002a070003010301700000201506091515b00000'
'c08f58c38860acb3a362fffe670ad13759a2a6b4c1a91116')
def test_later_chunk_concat_only(self):
self.assertEqual(
self._build(self.CHUNK, chunk_total=3, chunk_num=2, include_cpi=True),
'4405812143f57ff6240512151730002805000301030200201506091515b00000'
'c08f58c38860acb3a362fffe670ad13759a2a6b4c1a91116')
class TestOtaReference(unittest.TestCase):
def test_ciphered_spi_06_09(self):
out, _ = _ota_reference('06', '09', '15', '15', 'b00000', '0000000001', APDU, K, K)
self.assertEqual(out, REFERENCE_VECTORS[('06', '09')])
def test_ciphered_spi_16_01(self):
out, _ = _ota_reference('16', '01', '15', '15', 'b00000', '0000000001', APDU, K, K)
self.assertEqual(out, REFERENCE_VECTORS[('16', '01')])
def test_unciphered_spi_02_09(self):
out, _ = _ota_reference('02', '09', '15', '15', 'b00000', '0000000001', APDU, K, K)
self.assertEqual(out, REFERENCE_VECTORS[('02', '09')])
def test_unciphered_rc_reference(self):
out, _ = _ota_reference('01', '09', '15', '15', 'b00000', '0000000001', APDU, K, K)
self.assertEqual(out, REFERENCE_VECTORS[('01', '09')])
def test_unciphered_cpl_is_0x001d(self):
# Regression: CPL counts octets from the CHL octet to the last octet
# of the secured data (29 here), it must NOT be len(out)-2 (27/0x001b).
out, _ = _ota_reference('02', '09', '15', '15', 'b00000', '0000000001', APDU, K, K)
self.assertEqual(out[:4], '001d')
self.assertEqual(len(out) // 2, 31)
def test_sysmocom_reference_vector(self):
# Public vector from pySim tests/unittests/test_ota.py (test_cmd_3des_ciphered).
out, _ = _ota_reference('04', '19', '35', '35', 'b00000', '0000000000', APDU, KIC3, KID3)
self.assertEqual(out, '00180d04193535b00000e3ec80a849b554421276af3883927c20')
def test_returns_spi_dict(self):
_, spi = _ota_reference('16', '01', '15', '15', 'b00000', '0000000001', APDU, K, K)
self.assertEqual(spi['counter'], 'counter_must_be_higher')
self.assertTrue(spi['ciphering'])
def test_aes128_ciphered_cc(self):
out, _ = _ota_reference('16', '19', '22', '22', 'b00011', '0000000011', AES_APDU, KIC_AES, KID_AES)
self.assertEqual(out, AES_REFERENCE_VECTORS[('16', '19')])
def test_aes128_unciphered_cc(self):
out, _ = _ota_reference('12', '09', '22', '22', 'b00011', '0000000011', AES_APDU, KIC_AES, KID_AES)
self.assertEqual(out, AES_REFERENCE_VECTORS[('12', '09')])
def test_aes128_counter_plus_one(self):
out, spi = _ota_reference('1e', '19', '22', '22', 'b00011', '0000000011', AES_APDU, KIC_AES, KID_AES)
self.assertEqual(out, AES_REFERENCE_VECTORS[('1e', '19')])
self.assertEqual(spi['counter'], 'counter_must_be_lower')
def test_ram_load_sequence_uses_full_240_byte_blocks(self):
# The one-SMS clamp was removed: the RAM path's default LOAD blocks
# are the GP maximum of 240 bytes, and each secured packet still fits
# the card's concatenation buffer.
from pysim_simple_server.server import _cap_apdu_sequence
seq = _cap_apdu_sequence('A000000003000000', 'A000000003000001',
'AA' * 600, block_size=240)
loads = [a for a in seq if a.startswith('80E8')]
self.assertGreater(len(loads), 1)
for apdu in loads:
self.assertLessEqual(int(apdu[8:10], 16), 240)
sp, _ = _build_secured_packet('16', '01', '15', '15', 'b00000',
'0000000001', apdu, K, K)
self.assertLessEqual(len(_split_secured_packet(bytes.fromhex(sp))),
SCP80_MAX_SEGMENTS)
class TestSmsConcatenation(unittest.TestCase):
"""SCP80 SMS concatenation: packet building and segment sending
(TS 31.115 4.2/4.3)."""
def test_secured_packet_matches_the_pysim_reference_for_one_sms(self):
# Our encoder only lifts pySim's single-SMS refusal; for packets that
# fit one SMS it must stay byte-identical to the pySim reference.
for spi1, spi2 in (('06', '09'), ('16', '01'), ('02', '09'), ('04', '19')):
ref, _ = _ota_reference(spi1, spi2, '15', '15', 'b00000',
'0000000001', APDU, K, K)
out, _ = _build_secured_packet(spi1, spi2, '15', '15', 'b00000',
'0000000001', APDU, K, K)
self.assertEqual(out, ref, (spi1, spi2))
def test_split_keeps_the_sms_user_data_budget(self):
self.assertEqual(_split_secured_packet(b'A' * SCP80_SINGLE_BYTES),
[b'A' * SCP80_SINGLE_BYTES])
parts = _split_secured_packet(b'A' * (SCP80_SINGLE_BYTES + 1))
self.assertEqual([len(p) for p in parts],
[SCP80_FIRST_BYTES, SCP80_SINGLE_BYTES + 1 - SCP80_FIRST_BYTES])
pkt = bytes(range(256)) * 2
parts = _split_secured_packet(pkt)
self.assertEqual(len(parts[0]), SCP80_FIRST_BYTES)
self.assertTrue(all(len(p) <= SCP80_NEXT_BYTES for p in parts[1:]))
self.assertEqual(b''.join(parts), pkt)
# Without the CPI IE the single-SM budget is the full 140 octets.
self.assertEqual(_split_secured_packet(b'A' * 140, include_cpi=False),
[b'A' * 140])
def test_unprotected_single_sm_keeps_the_chl_first_form(self):
# TS 31.115 Table 1 NOTE: the CPL is "not absolutely necessary" in a
# single SM - an unprotected packet keeps pySim's CHL-first form.
out, _ = _build_secured_packet('00', '09', '15', '15', 'b00000',
'0000000001', APDU, K, K)
self.assertEqual(out[:2], '0d')
def test_unprotected_concatenated_packet_gains_the_cpl(self):
# ... but it is required once the packet needs concatenation
# (TS 31.115 Table 1 NOTE / 4.3).
out, _ = _build_secured_packet('00', '09', '15', '15', 'b00000',
'0000000001', 'A0' * 200, K, K)
self.assertEqual(len(out) // 2, 216)
self.assertEqual(out[:4], '00d6') # CPL = 214 = CHL..end
self.assertEqual(int(out[:4], 16), len(out) // 2 - 2)
def test_240_byte_load_block_encodes_and_fits_the_card_buffer(self):
# The RAM path no longer clamps LOAD blocks to one SMS: a 240-byte
# block (the GP maximum) becomes a concatenated command.
apdu = '80E80000F0' + '00' * 240 + '00'
out, _ = _build_secured_packet('16', '01', '15', '15', 'b00000',
'0000000001', apdu, K, K)
self.assertGreater(len(out) // 2, 140)
parts = _split_secured_packet(bytes.fromhex(out))
self.assertTrue(2 <= len(parts) <= SCP80_MAX_SEGMENTS, len(parts))
# pySim still refuses the same command - the reason we build it here.
with self.assertRaises(ValueError):
_ota_reference('16', '01', '15', '15', 'b00000', '0000000001', apdu, K, K)
def test_send_secured_packet_sends_the_segments_in_order(self):
import pysim_simple_server.server as srv
apdu = '80E80000F0' + '00' * 240 + '00'
sp_hex, _ = _build_secured_packet('16', '01', '15', '15', 'b00000',
'0000000001', apdu, K, K)
sent = []
def fake_envelope(tpdu_hex, scc, sm_sc=None, submit_handler=None):
sent.append(tpdu_hex.upper())
return '', '9000'
with mock.patch.object(srv, '_send_envelope', side_effect=fake_envelope):
result = _send_secured_packet(object(), sp_hex, oa_number='12345')
self.assertTrue(result['success'], result)
self.assertEqual(result['bytes'], len(sp_hex) // 2)
self.assertEqual(result['segments'], len(sent))
self.assertTrue(2 <= result['segments'] <= SCP80_MAX_SEGMENTS)
total = result['segments']
for num, tpdu in enumerate(sent, start=1):
concat = '000301%02X%02X' % (total, num) # IEI 00, IEDL 3, ref 01
udhl = '07' if num == 1 else '05'
self.assertIn(udhl + concat, tpdu, num)
if num == 1:
self.assertIn(udhl + concat + '7000', tpdu) # CPI in the first SM
else:
self.assertNotIn(concat + '7000', tpdu)
def test_send_secured_packet_refuses_more_than_the_card_buffer(self):
length = SCP80_FIRST_BYTES + SCP80_NEXT_BYTES * (SCP80_MAX_SEGMENTS - 1) + 1
result = _send_secured_packet(object(), '00' * length, oa_number='12345')
self.assertFalse(result['success'])
self.assertIn('too large', result['error'])
self.assertEqual(result['segments'], SCP80_MAX_SEGMENTS + 1)
def test_send_secured_packet_rejects_bad_hex(self):
result = _send_secured_packet(object(), 'zz', oa_number='12345')
self.assertFalse(result['success'])
self.assertIn('Invalid secured packet', result['error'])
def test_send_secured_packet_reports_a_failed_envelope(self):
import pysim_simple_server.server as srv
def fake_envelope(*args, **kwargs):
return '', '6F00'
with mock.patch.object(srv, '_send_envelope', side_effect=fake_envelope):
result = _send_secured_packet(object(), '00' * 10, oa_number='12345')
self.assertFalse(result['success'])
self.assertEqual(result['sw'], '6F00')
self.assertEqual(result['bytes'], 10)
self.assertIn('segment 1', result['error'])
def test_sms_user_data_budget_is_enforced(self):
# The splitter sizes every part exactly; a caller passing more than
# the SM can carry gets a clear error instead of an invalid TPDU.
with self.assertRaises(ValueError):
_build_sms_tpdu('00' * (SCP80_SINGLE_BYTES + 1))
with self.assertRaises(ValueError):
_build_sms_tpdu('00' * 141, include_cpi=False)
with self.assertRaises(ValueError):
_build_sms_tpdu('00' * (SCP80_FIRST_BYTES + 1), chunk_total=2, chunk_num=1)
# The exact capacities are all accepted.
self.assertTrue(_build_sms_tpdu('00' * SCP80_SINGLE_BYTES))
self.assertTrue(_build_sms_tpdu('00' * SCP80_FIRST_BYTES, chunk_total=2, chunk_num=1))
self.assertTrue(_build_sms_tpdu('00' * SCP80_NEXT_BYTES, chunk_total=2, chunk_num=2))
self.assertTrue(_build_sms_tpdu('00' * 140, include_cpi=False))
def test_segment_cap_matches_the_envelope_segment_limit(self):
from pysim_simple_server.server import MAX_ENVELOPE_SEGMENTS
self.assertEqual(SCP80_MAX_SEGMENTS, MAX_ENVELOPE_SEGMENTS)
self.assertEqual(SCP80_MAX_SEGMENTS, 5)
class TestDecodePor(unittest.TestCase):
def test_plaintext_no_cc_synthetic(self):
r = _decode_por('02', '01', '15', '15', '0000000001', K, K,
'027100000e0ab0000000000000010000016e00')
self.assertEqual(r['response_status'], 'por_ok')
self.assertEqual(r['tar'], 'B00000')
self.assertEqual(r['decoded']['last_status_word'], '6e00')
def test_sysmocom_signed(self):
r = _decode_por('06', '09', '35', '35', '0000000001', KIC3, KID3,
'027100001612b000110000000000000055f47118381175fb01612f')
self.assertEqual(r['response_status'], 'por_ok')
self.assertEqual(r['decoded']['last_status_word'], '612f')
def test_sysmocom_ciphered(self):
r = _decode_por('06', '19', '35', '35', '0000000001', KIC3, KID3,
'027100001c12b000119660ebdb81be189b5e4389e9e7ab2bc0954f963ad869ed7c')
self.assertEqual(r['response_status'], 'por_ok')
self.assertEqual(r['decoded']['last_status_word'], '612f')
def test_sysmocom_no_cc(self):
r = _decode_por('06', '01', '35', '35', '0000000001', KIC3, KID3,
'027100000e0ab000110000000000000001612f')
self.assertEqual(r['response_status'], 'por_ok')
self.assertEqual(r['decoded']['last_status_word'], '612f')
def test_complete_field_report(self):
"""All parsed PoR fields are surfaced verbatim (v1.9.4)."""
raw = '027100000e0ab000110000000000000001612f'
r = _decode_por('06', '01', '35', '35', '0000000001', KIC3, KID3, raw)
self.assertEqual(r['response_status'], 'por_ok')
self.assertEqual(r['tar'], 'B00011')
self.assertEqual(r['cntr'], '0000000000')
self.assertEqual(r['pcntr'], 0)
self.assertEqual(r['rpl'], 14)
self.assertEqual(r['rhl'], 10)
self.assertEqual(r['cc_rc'], '')
self.assertEqual(r['raw'], raw)
self.assertNotIn('cntr_low', str(r))
def test_cntr_low_fields(self):
r = _decode_por('02', '01', '15', '15', '0000000001', K, K,
'027100000b0ab0000000000000070002')
self.assertEqual(r['response_status'], 'cntr_low')
self.assertEqual(r['tar'], 'B00000')
self.assertEqual(r['cntr'], '0000000007')
self.assertEqual(r['rpl'], 11)
self.assertEqual(r['rhl'], 10)
self.assertIsNone(r.get('decoded'))
def test_sysmocom_bad_cc_returns_none(self):
r = _decode_por('06', '09', '35', '35', '0000000001', KIC3, KID3,
'027100001612b000110000000000000055f47118381175fb02612f')
self.assertIsNone(r)
def test_aes128_ciphered(self):
r = _decode_por('06', '19', '22', '22', '0000000001', KIC_AES, KID_AES,
'027100002412b00011ebc6b497e2cad7aedf36ace0e3a29b38853f0fe9ccde81913be5702b73abce1f')
self.assertEqual(r['response_status'], 'por_ok')
self.assertEqual(r['decoded']['last_status_word'], '6132')
def test_malformed_returns_none(self):
for bad in ['', '00', '00027100000e0a', '027100000e0ab00000', 'garbage', 'zz']:
self.assertIsNone(
_decode_por('02', '01', '15', '15', '0000000001', K, K, bad),
msg='expected None for %r' % bad)
class TestProactiveDecode(unittest.TestCase):
"""Server-side proactive command/TR decode helpers (v1.8.0 log feature)."""
def setUp(self):
import pysim_simple_server.server as srv
srv._PROACTIVE_SESSION_START = 1234.0
srv._PLI_DATA[0x00] = '93055210011000'
@staticmethod
def _cmd_raw(cmd_type, qualifier, extras=b''):
"""A D0-wrapped proactive command (header TLVs + extras)."""
body = (bytes([0x81, 0x03, 0x01, cmd_type, qualifier])
+ bytes([0x82, 0x02, 0x83, 0x81]) + extras)
return bytes([0xD0, len(body)]) + body
@staticmethod
def _decoded(cmd_type, raw, qualifier=None):
return {d['label']: d['value'] for d in _decode_cmd(cmd_type, raw, qualifier)}
def test_decode_cmd_poll_interval(self):
r = _decode_cmd(0x03, bytes.fromhex('d00d8103010300820283818402011e'), None)
self.assertEqual(r, [{'label': 'Interval', 'value': '30 s'}])
def test_decode_cmd_setup_event_list(self):
r = _decode_cmd(0x05, bytes.fromhex('d00c810301050082028381990101'), None)
self.assertEqual(r, [{'label': 'Events', 'value': 'Call connected'}])
def test_decode_cmd_set_up_menu_with_next_action(self):
# TS 102 223 8.24: one NAI byte per item, in item order (UCS2 item text).
extras = bytes.fromhex(
'8F0A0180004D0065006E0075' # 1: 'Menu'
'8F0A028000430061006C006C' # 2: 'Call'
'18022510') # NAI: SET UP MENU, SET UP CALL
raw = self._cmd_raw(0x25, 0x00, extras)
items = _parse_setup_menu_items(raw)
self.assertEqual([it['nai'] for it in items], [0x25, 0x10])
self.assertEqual([it['nai_name'] for it in items], ['SET UP MENU', 'SET UP CALL'])
self.assertEqual(self._decoded(0x25, raw)['Items'],
'1. Menu \u2192 SET UP MENU, 2. Call \u2192 SET UP CALL')
def test_decode_cmd_next_action_reserved_is_ignored(self):
# '26' (PROVIDE LOCAL INFORMATION) is Type-of-Command only, so it is
# reserved for the NAI and shall be ignored (8.24).
extras = bytes.fromhex('8F0A0180004D0065006E0075' '180126')
raw = self._cmd_raw(0x25, 0x00, extras)
items = _parse_setup_menu_items(raw)
self.assertNotIn('nai', items[0])
self.assertNotIn('nai_name', items[0])
self.assertEqual(self._decoded(0x25, raw)['Items'], '1. Menu')
def test_decode_cmd_select_item_short_next_action_list(self):
# A short NAI list leaves the tail without an indicator; extra bytes
# beyond the item count are ignored (8.24).
extras = bytes.fromhex(
'8F0A0180004D0065006E0075'
'8F0A028000430061006C006C'
'180121')
raw = self._cmd_raw(0x24, 0x00, extras)
items = _parse_select_item(raw)
self.assertEqual(items[0]['nai_name'], 'DISPLAY TEXT')
self.assertNotIn('nai_name', items[1])
self.assertEqual(self._decoded(0x24, raw)['Items'],
'1. Menu \u2192 DISPLAY TEXT, 2. Call')
def test_decode_cmd_send_short_message(self):
# SEND SHORT MESSAGE with an SMS-SUBMIT TPDU carrying GSM-7 text.
tpdu = bytes.fromhex('010006912143F5000005E8329BFD06')
raw = self._cmd_raw(0x13, 0, bytes([0x8B, len(tpdu)]) + tpdu)
r = self._decoded(0x13, raw)
self.assertEqual(r['Type'], 'SMS-SUBMIT')
self.assertEqual(r['TP-MR'], '0')
self.assertEqual(r['TP-DA'], '12345')
self.assertEqual(r['TP-PID'], '0x00')
self.assertEqual(r['TP-DCS'], '0x00')
self.assertEqual(r['TP-UDL'], '5')
self.assertEqual(r['Text'], 'hello')
self.assertEqual(r['SMS TPDU'], tpdu.hex().upper())
def test_decode_cmd_send_short_message_udh_8bit(self):
# UDHI + concatenation IE (16-bit ref) + 8-bit text data.
udh = bytes.fromhex('0608040001020341 42'.replace(' ', ''))
tpdu = (bytes.fromhex('4100' '06912143F5' '00' '04' '09') + udh)
raw = self._cmd_raw(0x13, 0, bytes([0x8B, len(tpdu)]) + tpdu)
r = self._decoded(0x13, raw)
self.assertEqual(r['Concat (16-bit ref)'], '1, part 2/3')
self.assertEqual(r['Text'], 'AB')
def test_decode_cmd_send_short_message_ucs2(self):
text = 'Тест'.encode('utf-16-be')
tpdu = (bytes.fromhex('0100' '06912143F5' '00' '08' '%02X' % len(text))
+ text)
raw = self._cmd_raw(0x13, 0, bytes([0x8B, len(tpdu)]) + tpdu)
r = self._decoded(0x13, raw)
self.assertEqual(r['TP-DCS'], '0x08')
self.assertEqual(r['Text'], 'Тест')
def test_decode_cmd_send_short_message_secured_packet(self):
# PID 0x7F = SIM data download: the UD is a secured packet (TS 31.115).
tpdu = bytes.fromhex('0100' '06912143F5' '7F' 'F6' '03' 'AABBCC')
raw = self._cmd_raw(0x13, 0, bytes([0x8B, len(tpdu)]) + tpdu)
r = self._decoded(0x13, raw)
self.assertEqual(r['TP-PID'], '0x7F (SIM data download)')
self.assertEqual(r['Secured packet (TS 31.115)'], '3 bytes: AABBCC')
def test_decode_cmd_send_short_message_malformed_falls_back(self):
# A malformed/garbage TPDU must not raise: the raw hex line remains.
raw = bytes.fromhex('d0158103011300820283818b0b916106152670f900a35f020101')
r = self._decoded(0x13, raw)
self.assertEqual(r['SMS TPDU'], '916106152670F900A35F02')
def test_decode_cmd_pli_qualifier_name(self):
r = _decode_cmd(0x26, b'\xd0', 0x00)
self.assertTrue(r[0]['value'].startswith('Location Information (MCC, MNC, LAC/TAC, Cell ID)'))
def test_decode_cmd_pli_all_standard_qualifiers_named(self):
# TS 102 223 V18.3.0 (PLI qualifier coding): names must exist even
# without a special data decoder, e.g. ESN (07) and MEID (0B).
cases = {
0x07: 'ESN',
0x0B: 'MEID',
0x1A: 'Supported Radio Access Technologies',
0x05: 'Reserved for GSM',
}
for qualifier, name in cases.items():
r = _decode_cmd(0x26, b'\xd0', qualifier)
self.assertIn(name, r[0]['value'], 'qualifier 0x%02X' % qualifier)
def test_decode_cmd_timer_management_start(self):
# TS 102 223 6.6.21/8.37/8.38: start timer 3 for 14:07:32
raw = bytes.fromhex('d011810301270082028182a40103a503417023')
self.assertEqual(_decode_cmd(0x27, raw, 0x00), [
{'label': 'Action', 'value': 'Start'},
{'label': 'Timer', 'value': '3'},
{'label': 'Value', 'value': '14:07:32'},
])
def test_decode_cmd_timer_management_plain_tags(self):
# Cards may use the plain (non comprehension-required) tag variant.
raw = bytes.fromhex('d00c010301270102028182240103')
self.assertEqual(_decode_cmd(0x27, raw, 0x01), [
{'label': 'Action', 'value': 'Deactivate'},
{'label': 'Timer', 'value': '3'},
])
def test_decode_cmd_open_channel_cr_tags(self):
# Same OPEN CHANNEL as the reference traces, but with CR-set TLVs.
raw = bytes.fromhex(
'd02b8103014001820281828500b50103b9020200c70b076d656761666f6e2e7275'
'bc03021f90be05217f000001')
r = _decode_cmd(0x40, raw, 0x01)
self.assertIn({'label': 'Bearer', 'value': '0x03'}, r)
self.assertIn({'label': 'Buffer size', 'value': '512'}, r)
self.assertIn({'label': 'APN', 'value': 'megafon.ru'}, r)
self.assertIn({'label': 'Destination', 'value': '127.0.0.1'}, r)
self.assertIn({'label': 'Transport', 'value': 'TCP client port 8080'}, r)
def test_decode_cmd_bip_channel_from_device_ids(self):
# Real trace: SEND DATA carries the channel in the device identities
# (source UICC 0x81, destination Channel 1 0x21).
raw = bytes.fromhex('d00e8103014301820281213701013603aabbcc')
r = _decode_cmd(0x43, raw, 0x01)
self.assertEqual(r[0], {'label': 'Channel', 'value': '1'})
self.assertEqual(r[1], {'label': 'Data bytes', 'value': '3'})
def test_parse_proactive_header_plain_tags(self):
import pysim_simple_server.server as srv
raw = bytes.fromhex('d00c010301270102028182240103')
self.assertEqual(srv._parse_proactive_header(raw), (1, 0x27, 0x81, 0x82, 0x01))
def test_default_handler_logs_timer_management(self):
# pySim's auto-handler path: the parsed command object (not the empty
# collection) is re-encoded for the log and used for the response.
import pysim_simple_server.server as srv
from pySim.cat import ProactiveCommand
from pySim.utils import h2b
srv._PROACTIVE_LOG.clear()
handler = srv._DefaultProactiveHandler()
pcmd = ProactiveCommand()
parsed = pcmd.from_tlv(h2b('d011810301270082028182a40103a503417023'))
ti = handler.receive_fetch_raw(pcmd, parsed)
tr = b''.join(x.to_tlv() for x in ti).hex()
self.assertTrue(tr.startswith('810301270082028281830100'), tr)
entry = srv._PROACTIVE_LOG[-1]
self.assertEqual(entry['type_hex'], '27')
self.assertEqual(entry['type_name'], 'TIMER MANAGEMENT')
self.assertEqual(entry['tr_result'], '00')
def test_default_handler_pli_includes_dict_data(self):
import pysim_simple_server.server as srv
from pySim.cat import ProactiveCommand
from pySim.utils import h2b
srv._PROACTIVE_LOG.clear()
srv._PLI_DATA[0x00] = '93055210011000'
handler = srv._DefaultProactiveHandler()
pcmd = ProactiveCommand()
parsed = pcmd.from_tlv(h2b('d00d810301260082028182'))
ti = handler.receive_fetch_raw(pcmd, parsed)
tr = b''.join(x.to_tlv() for x in ti).hex()
self.assertIn('93055210011000', tr)
self.assertEqual(srv._PROACTIVE_LOG[-1]['tr_hex'], '93055210011000')
def test_decode_cmd_empty_raw(self):
self.assertEqual(_decode_cmd(0x26, b'', None), [])
self.assertEqual(_decode_cmd(0x03, None, None), [])
def test_decode_tr_pli_location(self):
r = _decode_tr('26', '00', '93055210011000')
self.assertEqual(r, [
{'label': 'MCC', 'value': '250'},
{'label': 'MNC', 'value': '11'},
{'label': 'LAC/TAC', 'value': '1000'},
])
def test_decode_tr_pli_imei(self):
r = _decode_tr('26', '01', '94082143658709214305')
self.assertEqual(r[0], {'label': 'IMEI', 'value': '123456789012345'})
def test_decode_tr_pli_access_technology(self):
r = _decode_tr('26', '06', 'bf0103')
self.assertEqual(r, [{'label': 'Access Technology', 'value': 'UTRAN (3)'}])
def test_decode_tr_pli_search_mode(self):
r = _decode_tr('26', '09', 'ad0101')
self.assertEqual(r, [{'label': 'Search Mode', 'value': 'Manual'}])
def test_decode_tr_poll_interval(self):
r = _decode_tr('03', None, '8402011e')
self.assertEqual(r, [{'label': 'Interval', 'value': '30 s'}])
def test_decode_tr_empty(self):
self.assertEqual(_decode_tr('26', '00', ''), [])
def test_tr_data_only_strips_boilerplate(self):
tr = bytes.fromhex('81030326008202818393055210011000030100')
self.assertEqual(_tr_data_only(tr).hex(), '93055210011000')
def test_build_and_record_tr(self):
entry = {'type_hex': '26', 'qualifier': '00'}
tr = _build_tr(None, 3, 0x26, 0x83, 0x81, 0x00)
_record_tr(entry, tr, '9000')
self.assertEqual(entry['tr_hex'], '93055210011000')
self.assertEqual(entry['tr_sw'], '9000')
self.assertEqual([f['label'] for f in entry['tr_decoded']], ['MCC', 'MNC', 'LAC/TAC'])
def test_record_tr_without_sw(self):
entry = {'type_hex': '26', 'qualifier': '00'}
_record_tr(entry, bytes.fromhex('93055210011000'))
self.assertNotIn('tr_sw', entry)
self.assertEqual(entry['tr_hex'], '93055210011000')
def test_log_proactive_fields(self):
entry = _log_proactive(0x26, b'\xd0', 0x00, 7)
self.assertEqual(entry['cmd_num'], 7)
self.assertEqual(entry['type_hex'], '26')
self.assertEqual(entry['qualifier'], '00')
self.assertEqual(entry['raw'], 'd0')
self.assertEqual(entry['cmd_decoded'][0]['label'], 'Qualifier')
self.assertIn('id', entry)
def test_log_proactive_malformed_raw(self):
entry = _log_proactive(0x21, bytes([0xD0, 0xFF]), 0x00)
self.assertEqual(entry['cmd_decoded'], [])
def test_log_proactive_no_cmd_num(self):
entry = _log_proactive(0x05, bytes.fromhex('990101'), None)
self.assertNotIn('cmd_num', entry)
def test_record_tr_basic_result(self):
entry = {'type_hex': '03', 'qualifier': None}
_record_tr(entry, bytes.fromhex('8103010300820281838402011e030100'))
self.assertEqual(entry['tr_result'], '00')
self.assertEqual(entry['tr_result_name'], 'Command performed successfully')
self.assertEqual(entry['tr_hex'], '8402011e')
def test_record_tr_general_result(self):
entry = {'type_hex': '26', 'qualifier': '00'}
_record_tr(entry, bytes.fromhex('8103032600820281839305521001100083022001'))
self.assertEqual(entry['tr_result'], '2001')
self.assertEqual(entry['tr_result_name'], 'ME currently unable to process command')
self.assertEqual(entry['tr_hex'], '93055210011000')
def test_record_tr_unknown_result(self):
entry = {'type_hex': '26', 'qualifier': '00'}
_record_tr(entry, bytes.fromhex('810303260082028181030107'))
self.assertEqual(entry['tr_result'], '07')
self.assertNotIn('tr_result_name', entry)
def test_record_tr_no_result_tlv(self):
entry = {'type_hex': '26', 'qualifier': '00'}
_record_tr(entry, bytes.fromhex('810303260082028181'))
self.assertNotIn('tr_result', entry)
class TestEventDownload(unittest.TestCase):
"""ENVELOPE (EVENT DOWNLOAD) assembly, TS 102 223 7.5.11."""
def _send(self, event_type, event_data):
import pysim_simple_server.server as srv
calls = []
class Tp:
def send_apdu(self, apdu):
calls.append(apdu)
return '', '9000'
class Scc:
cat_cla = '80'
_tp = Tp()
data, sw = srv._send_event_download(Scc(), event_type, event_data)
return calls[0], sw
def test_channel_status_event(self):
# Event list + device identities + Channel status (8.56): channel 2,
# link established, info 05 = link dropped.
apdu, sw = self._send(0x0A, bytes.fromhex('b8028205'))
self.assertEqual(sw, '9000')
self.assertEqual(apdu, '80c200000dd60b99010a82028281b8028205')
def test_event_without_data(self):
apdu, sw = self._send(0x05, None)
self.assertEqual(sw, '9000')
self.assertEqual(apdu, '80c2000009d60799010582028281')
class TestTimerManagement(unittest.TestCase):
"""Terminal side of TIMER MANAGEMENT (TS 102 223 6.6.21, 6.8.13/14, 7.4).
The start vector is the live card's: timer 1, 60 s."""
START = bytes.fromhex('d011810301270082028182a40101a503001000')
def tearDown(self):
import pysim_simple_server.server as srv
srv._timer_cancel()
def test_hms_bcd_roundtrip(self):
import pysim_simple_server.server as srv
self.assertEqual(srv._hms_bcd(60).hex(), '001000')
self.assertEqual(srv._hms_bcd(3723).hex(), '102030')
self.assertEqual([srv._bcd_swap(b) for b in srv._hms_bcd(3723)], [1, 2, 3])
def test_start_returns_result_only_and_arms_timer(self):
import pysim_simple_server.server as srv
tr = srv._handle_timer_command(1, 0x27, 0x00, self.START, 0x81, 0x82)
self.assertEqual(tr.hex(), '810301270082028281030100')
remaining = srv._timer_remaining(1)
self.assertTrue(55 <= remaining <= 60, remaining)
def test_get_returns_remaining_value(self):
import pysim_simple_server.server as srv
srv._handle_timer_command(1, 0x27, 0x00, self.START, 0x81, 0x82)
tr = srv._handle_timer_command(1, 0x27, 0x02, self.START, 0x81, 0x82)
self.assertEqual(tr.hex(), '810301270282028281a40101a503001000030100')
def test_deactivate_stops_and_reports_value(self):
import pysim_simple_server.server as srv
srv._handle_timer_command(1, 0x27, 0x00, self.START, 0x81, 0x82)
tr = srv._handle_timer_command(1, 0x27, 0x01, self.START, 0x81, 0x82)
self.assertTrue(tr.hex().startswith('8103012701'), tr.hex())
self.assertIn('a40101a503001000', tr.hex())
self.assertIsNone(srv._timer_remaining(1))
def test_get_on_stopped_timer_is_contradiction(self):
import pysim_simple_server.server as srv
tr = srv._handle_timer_command(1, 0x27, 0x02, self.START, 0x81, 0x82)
self.assertEqual(tr.hex(), '810301270282028281030124')
def test_timer_expiration_envelope(self):
import pysim_simple_server.server as srv
calls = []
class Tp:
def send_apdu(self, apdu):
calls.append(apdu)
return '', '9000'
ref = types.SimpleNamespace(
scc=types.SimpleNamespace(cat_cla='80', _tp=Tp()), stk_pending=None)
with mock.patch.object(srv, '_server_ref', ref):
with mock.patch.object(srv, '_CARD_CONNECTED', True):
srv._timer_expired(1, 60)
# D7 0C: device identities (terminal -> UICC), Timer id A4, value A5
self.assertEqual(calls, ['80c200000ed70c82028281a40101a503001000'])
def test_cancelled_timer_does_not_report(self):
import pysim_simple_server.server as srv
calls = []
class Tp:
def send_apdu(self, apdu):
calls.append(apdu)
return '', '9000'
ref = types.SimpleNamespace(
scc=types.SimpleNamespace(cat_cla='80', _tp=Tp()), stk_pending=None)
with mock.patch.object(srv, '_server_ref', ref):
with mock.patch.object(srv, '_CARD_CONNECTED', True):
srv._timer_fire(1, 60) # never started/cancelled
self.assertEqual(calls, [])
def test_decode_tr_timer(self):
tr = bytes.fromhex('810301270082028281a40101a503001000030100')
data = _tr_data_only(tr).hex()
r = _decode_tr('27', '00', data)
self.assertEqual(r, [{'label': 'Timer', 'value': '1'},
{'label': 'Remaining', 'value': '00:01:00'}])
class TestExpandedRemoteResponse(unittest.TestCase):
"""Expanded Remote Response parsing (TS 102 226 §5.2.2)."""
def test_expanded_response_single_command(self):
entry = {'type_hex': '03', 'qualifier': None}
_record_tr(entry, bytes.fromhex('810301030082028183030100'))
self.assertEqual(entry['tr_result'], '00')
self.assertEqual(entry['tr_result_name'], 'Command performed successfully')
def test_expanded_response_parser_single_command(self):
# Simple test of the construct parsing structure
try:
from construct import Struct, Int8ub, Bytes, GreedyBytes, Optional, Array, this
from osmocom.utils import b2h
# Create sample expanded response data
secured_data = bytes.fromhex('01' '01' '9000' '11') # response_count, cmd#, SW, data
ExpandedRemoteResponse = Struct(
'response_count'/Int8ub,
'responses'/Array(this.response_count, Struct(
'command_number'/Int8ub,
'status_word'/Bytes(2),
'response_data'/GreedyBytes,
'error_details'/Optional(Struct(
'error_code'/Int8ub,
'error_info'/GreedyBytes
)),
'chaining_context'/Optional(Struct(
'script_id'/Bytes(4),
'is_first'/Int8ub,
'is_last'/Int8ub,
))
))
)
expanded = ExpandedRemoteResponse.parse(secured_data)
self.assertEqual(expanded.response_count, 1)
self.assertEqual(expanded.responses[0].command_number, 1)
self.assertEqual(b2h(expanded.responses[0].status_word).upper(), '9000')
self.assertEqual(b2h(expanded.responses[0].response_data).upper(), '11')
except Exception as e:
self.fail(f"ExpandedRemoteResponse parsing failed: {e}")
def test_expanded_response_with_error(self):
entry = {'type_hex': '26', 'qualifier': '01'}
# Result TLV: 03 01 6A (error_code 0x6A)
_record_tr(entry, bytes.fromhex('81030326008202818303016A'))
self.assertEqual(entry['tr_result'], '6a')
self.assertEqual(entry['tr_result_name'], 'Command performed with limited understanding')
def test_expanded_response_with_chaining(self):
entry = {'type_hex': '26', 'qualifier': '00'}
# Result: 03 01 00 + chaining context with script_id
_record_tr(entry, bytes.fromhex('81030326008202818303010093'))
self.assertEqual(entry['tr_result'], '00')
self.assertEqual(entry['tr_result_name'], 'Command performed successfully')
class TestSmsConcat(unittest.TestCase):
"""Tests for _parse_sms_concat — SMS UDH concatenation parsing."""
def test_no_udh_sms_submit(self):
"""SMS-SUBMIT without TP-UDHI: entire UD is payload."""
from pysim_simple_server.server import _parse_sms_concat
# First octet 0x01: MTI=01 (SUBMIT), no UDH, no VP
# MR=00, DA_len=05, DA_type=90, DA=2143F5, PID=00, DCS=04, UDL=03, UD=AABBCC
tpdu = bytes.fromhex('0100' # first octet + MR
'05' # DA length
'90' # DA type
'2143F5' # DA data (3 bytes for 5 digits)
'0004' # PID + DCS
'03' # UDL
'AABBCC') # UD (payload)
ref, total, num, payload = _parse_sms_concat(tpdu)
self.assertIsNone(ref)
self.assertIsNone(total)
self.assertIsNone(num)
self.assertEqual(payload.hex(), 'aabbcc')
def test_8bit_concat_iei_0x00(self):
"""SMS-SUBMIT with IEI 0x00 (8-bit reference concatenation)."""
from pysim_simple_server.server import _parse_sms_concat
# First octet 0x41: MTI=01 (SUBMIT), TP-UDHI=1, no VP
# MR=00, DA_len=05, DA_type=90, DA=2143F5, PID=00, DCS=04
# UDL=09, UDHL=05, UDH: 00 03 04 04 01 (concat IE), payload=AABBCC
tpdu = bytes.fromhex('4100' # first octet + MR
'05' # DA length
'90' # DA type
'2143F5' # DA data
'0004' # PID + DCS
'09' # UDL (1 UDHL + 5 UDH + 3 payload = 9)
'05' # UDHL = 5 bytes of UDH
'0003' # IEI=0x00, IEDL=3
'04' # ref
'04' # total (4 segments)
'01' # num (segment 1)
'AABBCC') # payload
ref, total, num, payload = _parse_sms_concat(tpdu)
self.assertEqual(ref, 0x04)
self.assertEqual(total, 4)
self.assertEqual(num, 1)
self.assertEqual(payload.hex(), 'aabbcc')
def test_16bit_concat_iei_0x08(self):
"""SMS-SUBMIT with IEI 0x08 (16-bit reference concatenation)."""
from pysim_simple_server.server import _parse_sms_concat
# First octet 0x41: MTI=01, TP-UDHI=1
# UDH: 06 (UDHL) 08 04 01 02 03 04 (16-bit concat: ref=0x0102, total=3, num=4)
# payload=FF
tpdu = bytes.fromhex('4100'
'05'
'90'
'2143F5'
'0004'
'08' # UDL (1 UDHL + 6 UDH + 1 payload = 8)
'06' # UDHL
'0804' # IEI=0x08, IEDL=4
'0102' # ref (16-bit, big-endian)
'03' # total
'04' # num
'FF') # payload
ref, total, num, payload = _parse_sms_concat(tpdu)
self.assertEqual(ref, 0x0102)
self.assertEqual(total, 3)
self.assertEqual(num, 4)
self.assertEqual(payload.hex(), 'ff')
def test_udh_with_cpi(self):
"""UDH with concatenation IE + CPI IE (0x70)."""
from pysim_simple_server.server import _parse_sms_concat
# First octet 0x41: MTI=01, TP-UDHI=1
# UDHL=07, UDH: 00 03 04 04 01 (concat) + 70 00 (CPI)
tpdu = bytes.fromhex('4100'
'05'
'90'
'2143F5'
'0004'
'0A' # UDL (1 UDHL + 7 UDH + 1 payload = 9? no: 1+5+2+1=9, but UDH=7 bytes)
'07' # UDHL = 7
'0003' # IEI=0x00, IEDL=3
'04' # ref
'04' # total
'01' # num
'7000' # CPI IE (IEI=0x70, IEDL=0)
'DD') # payload
ref, total, num, payload = _parse_sms_concat(tpdu)
self.assertEqual(ref, 0x04)
self.assertEqual(total, 4)
self.assertEqual(num, 1)
self.assertEqual(payload.hex(), 'dd')
def test_empty_payload(self):
"""Segment with empty payload after UDH."""
from pysim_simple_server.server import _parse_sms_concat
# First octet 0x41: MTI=01, TP-UDHI=1
tpdu = bytes.fromhex('4100'
'05'
'90'
'2143F5'
'0004'
'06' # UDL (1 UDHL + 5 UDH + 0 payload = 6)
'05' # UDHL
'0003'
'01'
'02'
'01') # no payload after UDH
ref, total, num, payload = _parse_sms_concat(tpdu)
self.assertEqual(ref, 0x01)
self.assertEqual(total, 2)
self.assertEqual(num, 1)
self.assertEqual(len(payload), 0)
def test_short_tpdu(self):
"""Truncated TPDU returns gracefully."""
from pysim_simple_server.server import _parse_sms_concat
ref, total, num, payload = _parse_sms_concat(b'\x01')
self.assertIsNone(ref)
self.assertIsNone(total)
self.assertIsNone(num)
def test_none_input(self):
"""None input returns empty payload."""
from pysim_simple_server.server import _parse_sms_concat
ref, total, num, payload = _parse_sms_concat(None)
self.assertIsNone(ref)
self.assertIsNone(total)
self.assertIsNone(num)
self.assertEqual(len(payload), 0)
def test_short_tpdu(self):
"""Truncated TPDU returns gracefully."""
from pysim_simple_server.server import _parse_sms_concat
ref, total, num, payload = _parse_sms_concat(b'\x44')
self.assertIsNone(ref)
self.assertIsNone(total)
self.assertIsNone(num)
def test_none_input(self):
"""None input returns empty payload."""
from pysim_simple_server.server import _parse_sms_concat
ref, total, num, payload = _parse_sms_concat(None)
self.assertIsNone(ref)
self.assertIsNone(total)
self.assertIsNone(num)
self.assertEqual(len(payload), 0)
class TestSmsReassembly(unittest.TestCase):
"""Tests for SMS segment reassembly logic."""
def test_single_segment_no_concat(self):
"""Single segment without UDH → submit_tpdu_hex is set directly."""
from pysim_simple_server.server import PoRSubmitHandler, _find_sms_tpdu, _parse_sms_concat
handler = PoRSubmitHandler()
# Build a simple D0 with tag 8B containing an SMS-SUBMIT without UDH
sms_tpdu = bytes.fromhex('040005902143F50004' # SMS-SUBMIT header
'03' # UDL
'AABBCC') # payload
# Wrap in D0 proactive command
d0 = bytes([0xD0, len(sms_tpdu) + 4, # approximate BER length
0x81, 0x03, 0x01, 0x13, 0x00, # Command Details
0x82, 0x02, 0x81, 0x83, # Device Identities
0x8B, len(sms_tpdu)]) # tag 8B
# Simulate _find_sms_tpdu extracting tag 8B
found = sms_tpdu.hex()
# Parse and check
ref, total, num, payload = _parse_sms_concat(sms_tpdu)
self.assertIsNone(ref)
handler.submit_tpdu_hex = found # single segment path
self.assertEqual(handler.submit_tpdu_hex, found)
def test_multi_segment_reassembly(self):
"""3 segments with IEI 0x00 in random order → assembled in correct order."""
from pysim_simple_server.server import PoRSubmitHandler
handler = PoRSubmitHandler()
# Segment payloads (after UDH)
payloads = [b'\x01\x02', b'\x03\x04', b'\x05\x06']
ref = 0x42
total = 3
# Simulate receiving segments in random order: 2, 0, 1
for idx in [1, 0, 2]:
num = idx + 1
handler.sms_segments.append((ref, total, num, payloads[idx].hex()))
# Check if all segments collected
matching = [s for s in handler.sms_segments if s[0] == ref]
if len(matching) >= total:
sorted_segs = sorted(matching, key=lambda s: s[2])
assembled = b''.join(bytes.fromhex(s[3]) for s in sorted_segs)
handler.submit_tpdu_hex = assembled.hex()
self.assertEqual(handler.submit_tpdu_hex, '010203040506')
def test_independent_references(self):
"""Two different reference numbers are independent."""
from pysim_simple_server.server import PoRSubmitHandler
handler = PoRSubmitHandler()
# Ref 0x01: 2 segments
handler.sms_segments.append((0x01, 2, 1, 'AA'))
handler.sms_segments.append((0x01, 2, 2, 'BB'))
matching = [s for s in handler.sms_segments if s[0] == 0x01]
if len(matching) >= 2:
sorted_segs = sorted(matching, key=lambda s: s[2])
assembled = b''.join(bytes.fromhex(s[3]) for s in sorted_segs)
handler.submit_tpdu_hex = assembled.hex()
self.assertEqual(handler.submit_tpdu_hex, 'aabb')
# Ref 0x02: 1 segment (independent)
handler.sms_segments.append((0x02, 1, 1, 'CC'))
matching2 = [s for s in handler.sms_segments if s[0] == 0x02]
if len(matching2) >= 1:
sorted_segs2 = sorted(matching2, key=lambda s: s[2])
assembled2 = b''.join(bytes.fromhex(s[3]) for s in sorted_segs2)
# Only update if ref 0x02 is complete
handler.submit_tpdu_hex = assembled2.hex()
# Last assembly was ref 0x02
self.assertEqual(handler.submit_tpdu_hex, 'cc')
if __name__ == '__main__':
unittest.main()
class CapApduSequenceTest(unittest.TestCase):
"""RAM APDU sequence shared by the SCP80 and SCP81 install paths."""
def _mini_cap(self):
import io, zipfile
# Header: tag(1) size(2) magic(4) minor(1) major(1) flags(1)
# pkg minor(1) pkg major(1) aid_len(1) aid(N)
header = (b'\x01\x00\x11' + b'\xde\xca\xff\xed' + b'\x00\x01\x00' +
b'\x00\x01' + b'\x06' + b'\xa0\x00\x00\x01\x00\x01')
# Applet: tag(1) size(2) count(1) aid_len(1) module_aid(N) offset(2)
applet = (b'\x03\x00\x0a\x01\x05' + b'\xa0\x00\x00\x01\x00' + b'\x00\x08')
buf = io.BytesIO()
zf = zipfile.ZipFile(buf, 'w')
zf.writestr('pkg/Header.cap', header)
zf.writestr('pkg/Applet.cap', applet)
zf.close()
return buf.getvalue().hex().upper()
def test_cap_parse(self):
from pysim_simple_server.server import _cap_parse
loadfile_aid, module_aid, data = _cap_parse(self._mini_cap())
self.assertEqual(loadfile_aid, 'A00000010001')
self.assertEqual(module_aid, 'A000000100')
# Header then Applet, per the CAP component order.
self.assertTrue(data.startswith('010011DECAFFED'))
self.assertIn('03000A01', data)
def test_sequence_install_load_install(self):
from pysim_simple_server.server import _cap_apdu_sequence
seq = _cap_apdu_sequence('A00000010001', 'A000000100', 'AABBCCDD')
# INSTALL [for load]: lv(pkg aid) + lv(ISD) + 000000
self.assertEqual(seq[0],
'80E6020013' + '06A00000010001' + '08A000000003000000' + '000000' + '00')
# One LOAD block (small payload, last -> P1=0x80, P2=0)
self.assertEqual(seq[1][:8], '80E88000')
self.assertTrue(seq[1].endswith('00'))
# INSTALL [for install]: C9 00 install params appended to the lv chain
self.assertTrue(seq[2].startswith('80E60C00'))
self.assertIn('06A00000010001' + '05A000000100' + '05A000000100' + '0100', seq[2])
def test_load_blocks_split_and_counter(self):
from pysim_simple_server.server import _cap_apdu_sequence, _ber_len as _ber_len_lower
data = ''.join('%02X' % (i % 256) for i in range(700))
seq = _cap_apdu_sequence('A00000010001', 'A000000100', data)
self.assertEqual(len(seq), 5) # INSTALL + 3 LOAD + INSTALL
self.assertEqual(seq[1][:8], '80E80000')
self.assertEqual(seq[2][:8], '80E80001')
self.assertEqual(seq[3][:8], '80E88002') # last block: P1=0x80
# The blocks are consecutive chunks and reassemble the load file TLV
# byte-for-byte (a shifted/overlapping split fails the card mid-load).
def payload(apdu):
lc = int(apdu[8:10], 16)
return apdu[10:10 + lc * 2]
joined = payload(seq[1]) + payload(seq[2]) + payload(seq[3])
self.assertTrue(joined.startswith('C482'))
expected = 'C4' + _ber_len_lower(700) + data # 700 = 0x2BC
self.assertEqual(joined.upper(), expected.upper())
self.assertEqual(int(seq[3][8:10], 16), len(expected) // 2 - 480)
def test_custom_block_size_splits_into_more_blocks(self):
# A smaller block size (SCP80: fit one SMS) slices the load file TLV
# into consecutive chunks of that size, the last block marked P1=0x80
# with the block counter in P2.
from pysim_simple_server.server import _cap_apdu_sequence
data = ''.join('%02X' % (i % 256) for i in range(700)) # TLV = 704 bytes
seq = _cap_apdu_sequence('A00000010001', 'A000000100', data, block_size=100)
self.assertEqual(len(seq), 10) # INSTALL + 8 LOAD + INSTALL
loads = seq[1:-1]
self.assertEqual(len(loads), 8)
for i, apdu in enumerate(loads):
self.assertEqual(apdu[:8], '80E8%s%02X' % ('80' if i == 7 else '00', i))
def payload(apdu):
lc = int(apdu[8:10], 16)
return apdu[10:10 + lc * 2]
joined = ''.join(payload(a) for a in loads)
self.assertEqual(len(joined) // 2, 704) # C4 82 02BC + 700 data bytes
self.assertTrue(joined.startswith('C482'))
self.assertEqual(int(loads[0][8:10], 16), 100)
self.assertEqual(int(loads[-1][8:10], 16), 4) # 704 = 7*100 + 4
def test_gen_install_returns_the_apdu_list(self):
# /api/scp81/gen-install: build the INSTALL/LOAD/INSTALL list for a
# .cap without touching any listener or script state.
from pysim_simple_server.server import _scp81_gen_install
resp = _scp81_gen_install({'cap_hex': self._mini_cap(), 'privileges': '01'})
self.assertTrue(resp['ok'], resp)
self.assertEqual(resp['load_file_aid'], 'A00000010001')
self.assertEqual(resp['module_aid'], 'A000000100')
self.assertEqual(len(resp['apdus']), 3)
self.assertTrue(resp['apdus'][0].startswith('80E60200'))
self.assertTrue(resp['apdus'][1].startswith('80E88000'))
self.assertTrue(resp['apdus'][2].startswith('80E60C00'))
self.assertNotIn('queued', resp) # generation only, no queueing
def test_gen_install_rejects_bad_input(self):
from pysim_simple_server.server import _scp81_gen_install
self.assertFalse(_scp81_gen_install({})['ok'])
resp = _scp81_gen_install({'cap_hex': '00'})
self.assertFalse(resp['ok'])
self.assertIn('cap parse failed', resp['error'])
class TerminalProfileTest(unittest.TestCase):
"""Runtime TERMINAL PROFILE: hex validation and re-send."""
def test_validate_tp_hex(self):
from pysim_simple_server.server import _validate_tp_hex
self.assertEqual(_validate_tp_hex('ff 00 80'), ('FF0080', None))
self.assertEqual(_validate_tp_hex('80FF'), ('80FF', None))
for bad in ('', ' ', 'F', 'XYZ', 'FF0', 'FF' * 256):
h, err = _validate_tp_hex(bad)
self.assertIsNone(h, bad)
self.assertTrue(err, bad)
def test_resend_terminal_profile_resets_state_and_sends(self):
import types
from pysim_simple_server import server as srv
server_obj = types.SimpleNamespace(
terminal_profile='FF00', stk_pending={'type': 'display_text'},
menu_active=True, event_list=[0x03], sim_menu='old')
seen = []
old_send = srv._send_terminal_profile
def fake_send(scc, tp):
seen.append(tp)
return 'menu', [0x09]
srv._send_terminal_profile = fake_send
try:
resp = srv._resend_terminal_profile(server_obj, object())
finally:
srv._send_terminal_profile = old_send
self.assertTrue(resp['ok'])
self.assertEqual(resp['profile'], 'FF00')
self.assertEqual(seen, ['FF00'])
self.assertIsNone(server_obj.stk_pending)
self.assertFalse(server_obj.menu_active)
self.assertEqual(server_obj.event_list, [0x09])
self.assertEqual(server_obj.sim_menu, 'menu')
self.assertTrue(resp['menu'])