Files
otaman/tests/test_httpota.py
T
catarrh 20fbfd99a5 scp81 passthru, proactive decoders, ADM badge, file manager layout (v2.2.1)
SCP81:
- New listener mode "passthru": no local listener - the card's BIP
  channels connect straight to a configured external platform
  (host/port required), which terminates TLS and runs the dialog.
  The status API reports mode/target (_SCP81_MODE/_SCP81_TARGET) and
  clears them on stop. PWA mode select, hint, Start validation; TLS PSK
  stays the default.

Proactive command decoding (Phone tab log):
- SEND SHORT MESSAGE (0x13) is now decoded: alpha, address (TON/NPI +
  number), 3GPP-SMS TPDU (type, TP-MR, TP-DA, TP-PID 0x7F flagged as
  SIM data download, TP-DCS, TP-VP, TP-UDL), UDH concatenation IEs, and
  the TP-UD as text (GSM-7 with a septet unpacker, UCS2, 8-bit) or as a
  TS 31.115 secured packet for PID 0x7F; malformed TPDUs fall back to
  the raw hex line.
- PROVIDE LOCAL INFORMATION qualifier names completed per TS 102 223
  V18.3.0: ESN (07), MEID (0B), Supported RATs (1A); 05 relabelled
  "Reserved for GSM (Timing Advance)". Fixed in the server dict and
  both frontend tables.

Header:
- Compact ADM badge next to the card indicator: "ADM ✓" green when
  pySim's adm_verified is set, "ADM ✗" red otherwise, hidden without a
  card session or when the server is down; updated ahead of the card
  state-key early return so it never disturbs the connect/reset flow.

File manager:
- Sort pills (FID/Name), Probe all files button and progress line are
  pinned above the tree instead of scrolling with it; the tree box cap
  grows from 420px to 65vh.

SW cache otaman-v165; help EN/RU updated; tests 234 Python + 361 frontend.
2026-09-16 15:29:01 +03:00

455 lines
18 KiB
Python

#!/usr/bin/env python3
"""Unit tests for the HTTP OTA (SCP81) BIP terminal emulation (Phase A).
TR byte vectors come from the captured real-terminal traces in
samples/HTTP_OTA/traces (OPEN CHANNEL success/failure, SEND/RECEIVE/CLOSE).
No live card or live card data is used here.
"""
import socket
import sys
import threading
import time
import types
import unittest
from pathlib import Path
from unittest import mock
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_otaman_server import httpota
import pysim_otaman_server.server as server
OPEN_LOCALHOST = bytes.fromhex(
'd02b010301400102028182050035010339020200470b076d656761666f6e2e7275'
'3c03021f903e05217f000001')
TR_OPEN_OK = '0103014001020282810301003802810035010339020200'
TR_OPEN_FAIL = '01030140010202828103023a0035010339020200'
class PeerServer(threading.Thread):
"""Tiny TCP peer: accepts one connection, greets, records what it receives."""
def __init__(self, greeting=b''):
super().__init__(daemon=True)
self.sock = socket.socket()
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.sock.bind(('127.0.0.1', 0))
self.sock.listen(1)
self.port = self.sock.getsockname()[1]
self.greeting = greeting
self.received = b''
self.conn = None
self.ready = threading.Event()
self.done = threading.Event()
def run(self):
self.sock.settimeout(3)
try:
self.conn, _ = self.sock.accept()
except OSError:
return
self.ready.set()
if self.greeting:
self.conn.sendall(self.greeting)
self.conn.settimeout(2)
deadline = time.time() + 3
try:
while time.time() < deadline:
try:
data = self.conn.recv(4096)
except socket.timeout:
break
if not data:
break
self.received += data
except OSError:
pass
self.done.set()
def stop(self):
self.sock.close()
def open_cmd(host, port, buffer_size=512):
ip = bytes(int(x) for x in host.split('.'))
tlvs = (b'\x81\x03\x01\x40\x01'
b'\x82\x02\x81\x82'
b'\x35\x01\x03'
b'\x39\x02' + buffer_size.to_bytes(2, 'big') +
b'\x3c\x03\x02' + port.to_bytes(2, 'big') +
b'\x3e\x05\x21' + ip)
return b'\xd0' + bytes([len(tlvs)]) + tlvs
def channel_cmd(cmd_type, qualifier, data_tlvs=b''):
tlvs = (bytes([0x81, 0x03, 0x01, cmd_type, qualifier]) +
b'\x82\x02\x81\x21' + data_tlvs)
return b'\xd0' + bytes([len(tlvs)]) + tlvs
class TlvTest(unittest.TestCase):
def test_proactive_tlvs_open_channel(self):
tlvs = httpota.proactive_tlvs(OPEN_LOCALHOST)
self.assertEqual(tlvs[httpota.TAG_BEARER], b'\x03')
self.assertEqual(tlvs[httpota.TAG_BUFFER_SIZE], b'\x02\x00')
self.assertEqual(tlvs[httpota.TAG_NAA], b'\x07megafon.ru')
self.assertEqual(httpota.parse_transport_level(tlvs[httpota.TAG_TRANSPORT_LEVEL]), (0x02, 8080))
self.assertEqual(httpota.parse_other_address(tlvs[httpota.TAG_OTHER_ADDRESS]), '127.0.0.1')
class TrVectorTest(unittest.TestCase):
def test_open_channel_success_vector(self):
extra = bytes([0x38, 0x02, 0x81, 0x00]) + bytes([0x35, 0x01, 0x03]) + bytes([0x39, 0x02, 0x02, 0x00])
tr = server._bip_tr(1, 0x40, 0x01, 0x81, 0x82, 0x00, None, extra)
self.assertEqual(tr.hex(), TR_OPEN_OK)
def test_open_channel_failure_vector(self):
extra = bytes([0x35, 0x01, 0x03]) + bytes([0x39, 0x02, 0x02, 0x00])
tr = server._bip_tr(1, 0x40, 0x01, 0x81, 0x82, 0x3A, 0x00, extra)
self.assertEqual(tr.hex(), TR_OPEN_FAIL)
def test_disabled_bip_fails_open_channel(self):
old = server._BIP
try:
server._BIP = httpota.BipTerminal()
tr = server._handle_bip_command(None, 1, 0x40, 0x01, OPEN_LOCALHOST, 0x81, 0x82)
self.assertEqual(tr.hex(), TR_OPEN_FAIL)
finally:
server._BIP = old
class BipTerminalTest(unittest.TestCase):
def test_redirect_and_roundtrip(self):
peer = PeerServer(greeting=b'SERVERHELLO')
peer.start()
bip = httpota.BipTerminal()
bip.enable('127.0.0.1', peer.port)
cid, err = bip.open('10.9.9.9', 1234, 512)
self.assertIsNone(err)
self.assertEqual(bip.channels[cid].requested, '10.9.9.9:1234')
self.assertEqual(bip.channels[cid].target, ('127.0.0.1', peer.port))
self.assertTrue(bip.send(cid, b'CLIENTHELLO'))
data = b''
for _ in range(20):
data = bip.receive(cid, 100)
if data:
break
time.sleep(0.05)
self.assertEqual(data, b'SERVERHELLO')
self.assertTrue(bip.close(cid))
peer.done.wait(3)
self.assertEqual(peer.received, b'CLIENTHELLO')
kinds = [e['kind'] for e in bip.entries_after(0)]
self.assertIn('open', kinds)
self.assertIn('send', kinds)
self.assertIn('receive', kinds)
self.assertIn('close', kinds)
peer.stop()
def test_passthru_mode_roundtrip_via_bip_control(self):
# SCP81 passthru: the control API enables BIP with the external
# platform as the target and starts no local listener; the card's
# channel talks straight to that platform.
peer = PeerServer(greeting=b'PLATFORM')
peer.start()
try:
resp = server._scp81_bip_control({'action': 'start', 'mode': 'passthru',
'host': '127.0.0.1', 'port': peer.port})
self.assertTrue(resp['ok'], resp)
self.assertEqual(resp['listener']['mode'], 'passthru')
self.assertEqual(server._BIP.target, ('127.0.0.1', peer.port))
cid, err = server._BIP.open('10.9.9.9', 10174, 512)
self.assertIsNone(err)
self.assertTrue(server._BIP.send(cid, b'CARDHELLO'))
data = b''
for _ in range(20):
data = server._BIP.receive(cid, 100)
if data:
break
time.sleep(0.05)
self.assertEqual(data, b'PLATFORM')
finally:
server._scp81_bip_control({'action': 'stop'})
peer.stop()
def test_disabled_terminal_refuses_open(self):
bip = httpota.BipTerminal()
cid, err = bip.open('127.0.0.1', 1, 512)
self.assertIsNone(cid)
self.assertIn('disabled', err)
def test_peer_close_queues_channel_status_event(self):
# TS 102 223 7.5.11: a link lost outside a proactive command must be
# reported to the UICC (channel id, link not established, info 05).
srv = socket.socket()
srv.bind(('127.0.0.1', 0))
srv.listen(1)
try:
bip = httpota.BipTerminal()
bip.enable('127.0.0.1', srv.getsockname()[1])
cid, err = bip.open('10.9.9.9', 1234, 512)
self.assertIsNone(err)
conn, _ = srv.accept()
conn.close()
events = []
for _ in range(40):
bip.receive(cid, 16)
events = bip.take_pending_events()
if events:
break
time.sleep(0.05)
self.assertEqual(events, [{'channel': cid, 'status': cid, 'info': 0x05}])
finally:
srv.close()
def test_channel_status_queued_once_per_channel(self):
bip = httpota.BipTerminal()
bip._queue_link_status(3)
bip._queue_link_status(3)
self.assertEqual(bip.take_pending_events(),
[{'channel': 3, 'status': 3, 'info': 0x05}])
self.assertEqual(bip.take_pending_events(), [])
def test_proactive_close_does_not_queue_status(self):
# A CLOSE CHANNEL proactive command is not an autonomous link change.
peer = PeerServer()
peer.start()
try:
bip = httpota.BipTerminal()
bip.enable('127.0.0.1', peer.port)
cid, err = bip.open('10.9.9.9', 1234, 512)
self.assertIsNone(err)
self.assertTrue(bip.close(cid))
self.assertEqual(bip.take_pending_events(), [])
finally:
peer.stop()
def test_dump_server_logs_received_bytes(self):
received = []
dump = httpota.TcpDumpServer('127.0.0.1', 0, on_rx=lambda peer, data: received.append(data))
c = socket.create_connection(('127.0.0.1', dump.port), timeout=2)
c.sendall(b'HELLOCARD')
deadline = time.time() + 2
while time.time() < deadline and not received:
time.sleep(0.02)
c.close()
dump.stop()
self.assertEqual(b''.join(received), b'HELLOCARD')
def parse_tr(tr):
"""Parse a BIP TERMINAL RESPONSE payload into {tag: value}."""
out = {}
off = 0
while off + 1 < len(tr):
tag, ln = tr[off], tr[off + 1]
out[tag] = tr[off + 2:off + 2 + ln]
off += 2 + ln
return out
class BipCommandFlowTest(unittest.TestCase):
def setUp(self):
self.peer = PeerServer(greeting=b'SERVERHELLO')
self.peer.start()
self.old = server._BIP
self.bip = httpota.BipTerminal()
self.bip.enable('127.0.0.1', self.peer.port)
server._BIP = self.bip
def tearDown(self):
server._BIP = self.old
self.peer.stop()
def test_open_send_receive_close_flow(self):
tr = server._handle_bip_command(None, 1, 0x40, 0x01, open_cmd('127.0.0.1', self.peer.port), 0x81, 0x82)
self.assertEqual(tr.hex(), TR_OPEN_OK)
tr = server._handle_bip_command(None, 1, 0x43, 0x01,
channel_cmd(0x43, 0x01, bytes([0x36, 0x08]) + b'CLIENTHE'),
0x81, 0x21)
tlvs = parse_tr(tr)
self.assertEqual(tlvs[0x01].hex(), '014301')
self.assertEqual(tlvs[0x02].hex(), '8281')
self.assertEqual(tlvs[0x03], b'\x00')
self.assertEqual(tlvs[0x37], b'\xff')
data = b''
for _ in range(20):
tr = server._handle_bip_command(None, 1, 0x42, 0x00, channel_cmd(0x42, 0x00, bytes([0x37, 0x01, 0x64])), 0x81, 0x21)
tlvs = parse_tr(tr)
if 0x36 in tlvs and tlvs[0x36]:
data += tlvs[0x36]
break
time.sleep(0.05)
self.assertEqual(data, b'SERVERHELLO')
self.assertEqual(tlvs[0x37], b'\x00')
tr = server._handle_bip_command(None, 1, 0x41, 0x00, channel_cmd(0x41, 0x00), 0x81, 0x21)
self.assertEqual(tr.hex(), '010301410002028281030100')
self.peer.done.wait(3)
self.assertEqual(self.peer.received, b'CLIENTHE')
def test_send_without_channel_fails(self):
tr = server._handle_bip_command(None, 1, 0x43, 0x01,
channel_cmd(0x43, 0x01, bytes([0x36, 0x01]) + b'X'),
0x81, 0x21)
tlvs = parse_tr(tr)
self.assertEqual(tlvs[0x03].hex(), '3a00')
def test_open_channel_cr_set_tlvs(self):
# Live card 2026-09-15: the fallback OPEN CHANNEL uses the CR-set tag
# variants (B5/B9/C7/BC/BE) - the handler must find them too.
raw = bytes.fromhex('d0248103014003820281828500b50103b902058e'
'c70403475042bc03020582be05215bd50502')
tr = server._handle_bip_command(None, 1, 0x40, 0x03, raw, 0x81, 0x82)
tlvs = parse_tr(tr)
self.assertEqual(tlvs[0x03], b'\x00')
self.assertIn(0x38, tlvs) # Channel status
self.assertIn(0x39, tlvs) # Buffer size echo
def test_open_channel_plain_tlvs(self):
# Same command with the plain tag variants (reference phone traces).
raw = bytes.fromhex('d02401030140030202818205003501033902058e'
'4704034750423c030205823e05215bd50502')
tr = server._handle_bip_command(None, 1, 0x40, 0x03, raw, 0x81, 0x82)
tlvs = parse_tr(tr)
self.assertEqual(tlvs[0x03], b'\x00')
self.assertIn(0x38, tlvs)
def test_open_channel_truncated_destination_accepted(self):
# Live card 2026-09-15: '3e 05' with no value (empty buffer quirk,
# same family as the reference openchannel_not_understood_no_apn
# trace). The emulation is permissive and opens the configured target.
raw = bytes.fromhex('d01c810301400c82028182850035010339020200'
'4701003c030227be3e05')
tr = server._handle_bip_command(None, 1, 0x40, 0x0C, raw, 0x81, 0x82)
tlvs = parse_tr(tr)
self.assertEqual(tlvs[0x03], b'\x00')
self.assertIn(0x38, tlvs)
kinds = [(e['kind'], e.get('note')) for e in self.bip.entries_after(0)]
self.assertIn(('open-relaxed', 'destination/transport not fully specified'), kinds)
def test_open_channel_without_transport_accepted(self):
# No transport level at all (bearer-level channel): still accepted.
raw = bytes.fromhex('d00d81030140018202818239020200')
tr = server._handle_bip_command(None, 1, 0x40, 0x01, raw, 0x81, 0x82)
tlvs = parse_tr(tr)
self.assertEqual(tlvs[0x03], b'\x00')
self.assertIn(0x38, tlvs)
kinds = [e['kind'] for e in self.bip.entries_after(0)]
self.assertIn('open-relaxed', kinds)
def test_background_open_queues_link_established(self):
# Qualifier 0x04 (background mode): the terminal must report the
# established link via ENVELOPE (Channel status) - 7.5.11.
raw = bytes.fromhex('d01c810301400c82028182850035010339020200'
'4701003c030227be3e05')
server._handle_bip_command(None, 1, 0x40, 0x0C, raw, 0x81, 0x82)
events = self.bip.take_pending_events()
self.assertEqual(len(events), 1)
self.assertEqual(events[0]['info'], 0x00)
self.assertTrue(events[0]['status'] & 0x80)
def test_flush_channel_events_when_subscribed(self):
sent = []
class Tp:
def send_apdu(self, apdu):
sent.append(apdu)
return '', '9000'
scc = types.SimpleNamespace(cat_cla='80', _tp=Tp())
self.bip._queue_link_status(2)
ref = types.SimpleNamespace(event_list=[0x09, 0x0A])
with mock.patch.object(server, '_server_ref', ref):
server._bip_flush_channel_events(scc)
# D6: event list (ch status), device ids, Channel status B8 02 02 05
# (channel 2, link not established, info 05 = link dropped)
self.assertEqual(sent, ['80c200000dd60b99010a82028281b8020205'])
self.assertEqual(self.bip.take_pending_events(), [])
def test_flush_skipped_without_subscription(self):
sent = []
class Tp:
def send_apdu(self, apdu):
sent.append(apdu)
return '', '9000'
scc = types.SimpleNamespace(cat_cla='80', _tp=Tp())
self.bip._queue_link_status(1)
ref = types.SimpleNamespace(event_list=[0x09])
with mock.patch.object(server, '_server_ref', ref):
server._bip_flush_channel_events(scc)
self.assertEqual(sent, [])
# Not subscribed: the event stays queued for a later card session.
self.assertEqual(self.bip.take_pending_events(),
[{'channel': 1, 'status': 1, 'info': 0x05}])
if __name__ == '__main__':
unittest.main()
def test_peer_close_reported_after_buffer_drained(self):
# A dropped link must not be signalled while server data still waits
# to be fetched: the card would abort the fetch mid-record. Drain
# first, then report.
srv = socket.socket()
srv.bind(('127.0.0.1', 0))
srv.listen(1)
try:
bip = httpota.BipTerminal()
bip.enable('127.0.0.1', srv.getsockname()[1])
cid, err = bip.open('10.9.9.9', 1234, 512)
self.assertIsNone(err)
conn, _ = srv.accept()
conn.sendall(b'response-bytes')
conn.close()
ch = bip.channels[cid]
for _ in range(40):
ch.pump()
if ch.rx and ch.peer_closed:
break
time.sleep(0.05)
self.assertTrue(ch.rx)
self.assertTrue(ch.peer_closed)
# Partial fetch: the link-dropped event must still be withheld.
bip.receive(cid, 5)
self.assertEqual(bip.take_pending_events(), [])
# Remaining bytes fetched: the event is reported now.
bip.receive(cid, 64)
self.assertEqual(bip.take_pending_events(),
[{'channel': cid, 'status': cid, 'info': 0x05}])
bip.close(cid)
finally:
srv.close()
def test_receive_data_tlv_long_form_length(self):
# A >127-byte channel data TLV must use the BER long form (0x81 len),
# as the reference terminal traces do (`36 81 ed` for 237 bytes).
import types
server = __import__('pysim_otaman_server.server', fromlist=['x'])
big = bytes(range(256)) * 1 # 256 bytes; take a slice below
ch = types.SimpleNamespace(rx=bytearray(b'\xAA' * 237))
class FakeBip:
def __init__(self): self.channels = {1: ch}
def receive(self, cid, n):
data = bytes(ch.rx[:min(n, len(ch.rx))]); del ch.rx[:len(data)]; return data
def available(self, cid): return len(ch.rx)
def log(self, *a, **k): pass
old = server._BIP
server._BIP = FakeBip()
try:
raw = bytes.fromhex('d00c8103014200820281213701ed')
tr = server._handle_bip_command(None, 1, 0x42, 0, raw, None, 0x21)
self.assertIn(b'\x36\x81\xed' + b'\xAA' * 237, tr)
finally:
server._BIP = old