20fbfd99a5
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.
455 lines
18 KiB
Python
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
|