scp81: PSK TLS server and command scripting (phases B/C) + BIP fix (v2.1.5)
- scp81.py: PSK TLS listener (stdlib ssl PSK callbacks) speaking the GP HTTP administration dialog; configurable framing (chunked/Content-Length, TLS record split, Apache-style/compact headers, Connection header, keep-alive, Next-URI template with %d, TLS version/cipher, answer delay, keylog for capture decryption) - server.py: script responder + Response Scripting parsing (AF/AB, 80/23 TLVs), memory decoder, SCP81 start options, terminal-side timer management, background-mode BIP events, permissive OPEN CHANNEL - BIP fix: the RECEIVE DATA channel-data TLV length is BER long form (36 81 <len>) above 127 bytes; a raw length byte is mis-parsed on the card, so the large TLS records never reached its stack (a live card fetched the script response and silently never processed it - endless resume). The card now executes scripts and returns R-APDUs: memory (13 applets, 50646 B NV free, 2402 B volatile), ISD, stored HTTP OTA parameters, ELF and application registries - frontend: SCP81 tab (listener, script selection, HTTP OTA log), phone event forms, i18n; service worker v141 - docs: api.md, scp81-findings.md (attempt matrix + root cause analysis); tools/scp81_decrypt.py decrypts listener captures via the keylog - tests: 187 python + 337 frontend
This commit is contained in:
@@ -10,8 +10,10 @@ 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'
|
||||
@@ -156,6 +158,52 @@ class BipTerminalTest(unittest.TestCase):
|
||||
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))
|
||||
@@ -229,6 +277,152 @@ class BipCommandFlowTest(unittest.TestCase):
|
||||
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
|
||||
|
||||
@@ -7,6 +7,7 @@ No live/sample card keys and no ICCIDs appear here.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
@@ -268,6 +269,80 @@ class TestProactiveDecode(unittest.TestCase):
|
||||
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_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_otaman_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_otaman_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_otaman_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), [])
|
||||
@@ -360,6 +435,122 @@ class TestProactiveDecode(unittest.TestCase):
|
||||
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_otaman_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_otaman_server.server as srv
|
||||
srv._timer_cancel()
|
||||
|
||||
def test_hms_bcd_roundtrip(self):
|
||||
import pysim_otaman_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_otaman_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_otaman_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_otaman_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_otaman_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_otaman_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_otaman_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)."""
|
||||
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Phase B tests: PSK TLS server, GP HTTP administration session, and the
|
||||
terminal's Data available notification (TS 102 223 7.5.10).
|
||||
|
||||
Synthetic PSK only; no live card data.
|
||||
"""
|
||||
|
||||
import socket
|
||||
import ssl
|
||||
import sys
|
||||
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 scp81
|
||||
import pysim_otaman_server.server as server
|
||||
|
||||
PSK = bytes.fromhex('00112233445566778899aabbccddeeff')
|
||||
IDENT = '89012345678901234567'
|
||||
|
||||
|
||||
class HttpParseTest(unittest.TestCase):
|
||||
def test_parse_request(self):
|
||||
raw = (b'POST /server/adminagent?cmd=1 HTTP/1.1\r\n'
|
||||
b'Host: 172.96.0.1\r\n'
|
||||
b'X-Admin-Protocol: globalplatform-remote-admin/1.0\r\n'
|
||||
b'X-Admin-From: 0123456789\r\n\r\n')
|
||||
method, target, headers = scp81.parse_http_request(raw)
|
||||
self.assertEqual(method, 'POST')
|
||||
self.assertEqual(target, '/server/adminagent?cmd=1')
|
||||
self.assertEqual(headers['host'], '172.96.0.1')
|
||||
self.assertEqual(headers['x-admin-from'], '0123456789')
|
||||
|
||||
def test_parse_request_malformed(self):
|
||||
with self.assertRaises(ValueError):
|
||||
scp81.parse_http_request(b'GARBAGE\r\n\r\n')
|
||||
|
||||
def test_decode_chunked(self):
|
||||
body = b'4\r\nABCD\r\n5\r\nEFGHI\r\n0\r\n\r\n'
|
||||
self.assertEqual(scp81.decode_chunked(body), b'ABCDEFGHI')
|
||||
|
||||
def test_build_response_sets_content_length(self):
|
||||
out = scp81.build_http_response(200, 'OK',
|
||||
{'Content-Type': scp81.GP_CT_COMMAND}, b'\x80\x01\x00')
|
||||
self.assertTrue(out.startswith(b'HTTP/1.1 200 OK\r\n'))
|
||||
self.assertIn(b'Content-Length: 3\r\n\r\n\x80\x01\x00', out)
|
||||
|
||||
def test_build_response_connection_header(self):
|
||||
out = scp81.build_http_response(200, 'OK', {}, b'\x01',
|
||||
connection='close')
|
||||
self.assertIn(b'Connection: close\r\n', out)
|
||||
out = scp81.build_http_response(200, 'OK', {}, b'\x01',
|
||||
compact=True, connection='keep-alive')
|
||||
self.assertIn(b'Connection:keep-alive\r\n', out)
|
||||
|
||||
def test_build_response_chunked(self):
|
||||
out = scp81.build_http_response(200, 'OK', {},
|
||||
b'\x80' * 150, chunked=True)
|
||||
self.assertIn(b'Transfer-Encoding: chunked', out)
|
||||
self.assertNotIn(b'Content-Length', out)
|
||||
head, _, body = out.partition(b'\r\n\r\n')
|
||||
self.assertEqual(scp81.decode_chunked(body), b'\x80' * 150)
|
||||
# 100-byte chunks like the reference admin server
|
||||
self.assertTrue(body.startswith(b'64\r\n'))
|
||||
|
||||
|
||||
class PskTlsServerTest(unittest.TestCase):
|
||||
def _client_ctx(self, identity=IDENT, psk=PSK):
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
ctx.maximum_version = ssl.TLSVersion.TLSv1_2
|
||||
ctx.set_ciphers('PSK-AES128-CBC-SHA256:PSK-AES128-CBC-SHA')
|
||||
ctx.set_psk_client_callback(lambda hint: (identity.encode(), psk))
|
||||
return ctx
|
||||
|
||||
def _connect(self, srv, ctx=None):
|
||||
sock = socket.create_connection(('127.0.0.1', srv.port), timeout=5)
|
||||
try:
|
||||
return (ctx or self._client_ctx()).wrap_socket(sock)
|
||||
except Exception:
|
||||
sock.close()
|
||||
raise
|
||||
|
||||
def test_handshake_and_204_session(self):
|
||||
logs = []
|
||||
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, identity=IDENT,
|
||||
on_log=lambda k, **f: logs.append((k, f)))
|
||||
try:
|
||||
tls = self._connect(srv)
|
||||
self.assertEqual(tls.version(), 'TLSv1.2')
|
||||
self.assertEqual(tls.cipher()[0], 'PSK-AES128-CBC-SHA256')
|
||||
tls.sendall(b'POST /server/adminagent?cmd=1 HTTP/1.1\r\n'
|
||||
b'Host: 172.96.0.1\r\n'
|
||||
b'X-Admin-Protocol: globalplatform-remote-admin/1.0\r\n'
|
||||
b'X-Admin-From: 0123456789\r\n\r\n')
|
||||
reply = tls.recv(4096)
|
||||
self.assertTrue(reply.startswith(b'HTTP/1.1 204 No Content\r\n'))
|
||||
self.assertIn(b'X-Admin-Protocol: globalplatform-remote-admin/1.0', reply)
|
||||
tls.close()
|
||||
deadline = time.time() + 3
|
||||
while time.time() < deadline and srv.identity_seen is None:
|
||||
time.sleep(0.05)
|
||||
self.assertEqual(srv.identity_seen, IDENT)
|
||||
kinds = [k for k, _ in logs]
|
||||
self.assertIn('tls-handshake', kinds)
|
||||
self.assertIn('tls-request', kinds)
|
||||
self.assertIn('tls-response', kinds)
|
||||
req = [f for k, f in logs if k == 'tls-request'][0]
|
||||
self.assertEqual(req['uri'], '/server/adminagent?cmd=1')
|
||||
self.assertEqual(req['agent'], '0123456789')
|
||||
finally:
|
||||
srv.stop()
|
||||
|
||||
def _recv(self, tls):
|
||||
try:
|
||||
return tls.recv(4096)
|
||||
except (ssl.SSLError, OSError):
|
||||
return b''
|
||||
|
||||
def _read_http(self, tls):
|
||||
buf = b''
|
||||
while b'\r\n\r\n' not in buf:
|
||||
chunk = self._recv(tls)
|
||||
if not chunk:
|
||||
return buf
|
||||
buf += chunk
|
||||
head, _, rest = buf.partition(b'\r\n\r\n')
|
||||
if b'transfer-encoding: chunked' in head.lower():
|
||||
while b'0\r\n\r\n' not in rest:
|
||||
chunk = self._recv(tls)
|
||||
if not chunk:
|
||||
break
|
||||
rest += chunk
|
||||
return head + b'\r\n\r\n' + rest
|
||||
length = 0
|
||||
for line in head.split(b'\r\n'):
|
||||
if line.lower().startswith(b'content-length:'):
|
||||
length = int(line.split(b':')[1])
|
||||
while len(rest) < length:
|
||||
chunk = self._recv(tls)
|
||||
if not chunk:
|
||||
break
|
||||
rest += chunk
|
||||
return head + b'\r\n\r\n' + rest
|
||||
|
||||
def test_scripted_session_over_tls(self):
|
||||
old_bip = server._BIP
|
||||
server._BIP = mock.Mock()
|
||||
server._BIP.log = lambda *a, **k: None
|
||||
server._SCP81_SCRIPT = ['80CAFF2100']
|
||||
server._SCP81_SCRIPT_SENT = 0
|
||||
server._SCP81_SCRIPT_RESULTS = []
|
||||
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK,
|
||||
responder=server._scp81_script_responder,
|
||||
keep_alive=True)
|
||||
try:
|
||||
tls = self._connect(srv)
|
||||
tls.sendall(b'POST /api/scp81 HTTP/1.1\r\nHost: 127.0.0.1\r\n'
|
||||
b'X-Admin-Protocol: globalplatform-remote-admin/1.0\r\n'
|
||||
b'X-Admin-From: otaman\r\n\r\n')
|
||||
reply = self._read_http(tls)
|
||||
self.assertIn(b'HTTP/1.1 200 OK', reply)
|
||||
self.assertIn(b'X-Admin-Next-URI: /api/scp81?req=1', reply)
|
||||
self.assertIn(bytes.fromhex('ae80220580caff21000000'), reply)
|
||||
# Respond with the R-APDU (Response Scripting template)
|
||||
body = bytes.fromhex('af80' '800101' '2304' '93059000' '0000')
|
||||
tls.sendall(b'POST /api/scp81?step=1 HTTP/1.1\r\n'
|
||||
b'X-Admin-Script-Status: ok\r\n'
|
||||
b'Content-Length: %d\r\n\r\n' % len(body) + body)
|
||||
reply = self._read_http(tls)
|
||||
self.assertIn(b'HTTP/1.1 204 No Content', reply)
|
||||
self.assertEqual(server._SCP81_SCRIPT_RESULTS[0]['sw'], '9000')
|
||||
tls.close()
|
||||
finally:
|
||||
server._BIP = old_bip
|
||||
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
|
||||
server._SCP81_SCRIPT_SENT = 0
|
||||
server._SCP81_SCRIPT_RESULTS = []
|
||||
srv.stop()
|
||||
|
||||
def test_response_closes_connection_without_keep_alive(self):
|
||||
# Default (keep_alive=False): the card's HTTP client seems to delimit
|
||||
# the response at connection close, so the server closes after each
|
||||
# response and the card starts a fresh session for its next POST.
|
||||
def responder(method, target, headers, body):
|
||||
return 200, {'X-Admin-Protocol': scp81.GP_PROTOCOL}, b'\x80\x01\x00'
|
||||
|
||||
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, responder=responder)
|
||||
try:
|
||||
tls = self._connect(srv)
|
||||
tls.sendall(b'POST /api/scp81 HTTP/1.1\r\n\r\n')
|
||||
reply = self._read_http(tls)
|
||||
self.assertTrue(reply.startswith(b'HTTP/1.1 200 OK'))
|
||||
self.assertEqual(self._recv(tls), b'') # server closed
|
||||
tls.close()
|
||||
finally:
|
||||
srv.stop()
|
||||
|
||||
def test_close_waits_for_drain_callback(self):
|
||||
# With keep_alive=False and a body, the listener calls on_before_close
|
||||
# (the server waits for the card to drain the BIP buffer) before
|
||||
# closing the connection.
|
||||
seen = []
|
||||
|
||||
def responder(method, target, headers, body):
|
||||
return 200, {'X-Admin-Protocol': scp81.GP_PROTOCOL}, b'\x80\x01\x00'
|
||||
|
||||
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, responder=responder,
|
||||
on_before_close=lambda peer: seen.append(peer))
|
||||
try:
|
||||
tls = self._connect(srv)
|
||||
client_port = tls.getsockname()[1]
|
||||
tls.sendall(b'POST /api/scp81 HTTP/1.1\r\n\r\n')
|
||||
reply = self._read_http(tls)
|
||||
self.assertTrue(reply.startswith(b'HTTP/1.1 200 OK'))
|
||||
self.assertIn(b'Connection: close', reply)
|
||||
# The server must send close_notify (clean TLS shutdown) before
|
||||
# closing: unwrap() succeeds only when the peer's close_notify
|
||||
# has been received.
|
||||
tls.settimeout(3.0)
|
||||
plain = tls.unwrap()
|
||||
# The close comes after the drain callback: EOF proves it ran.
|
||||
self.assertEqual(plain.recv(1), b'')
|
||||
self.assertEqual(len(seen), 1)
|
||||
self.assertEqual(seen[0][1], client_port)
|
||||
plain.close()
|
||||
finally:
|
||||
srv.stop()
|
||||
|
||||
def test_wrong_identity_rejected(self):
|
||||
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, identity=IDENT)
|
||||
try:
|
||||
with self.assertRaises(ssl.SSLError):
|
||||
self._connect(srv, self._client_ctx(identity='unknown-id'))
|
||||
finally:
|
||||
srv.stop()
|
||||
|
||||
def test_server_hello_omits_encrypt_then_mac(self):
|
||||
# The live card offers encrypt_then_mac but aborts with
|
||||
# SSLV3_ALERT_UNEXPECTED_MESSAGE when the server echoes it.
|
||||
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK)
|
||||
try:
|
||||
sock = socket.create_connection(('127.0.0.1', srv.port), timeout=5)
|
||||
hello = bytes.fromhex(
|
||||
'1603010038' '01000034' '0303' + '11' * 32 + '00'
|
||||
'0002' '00ae' '0100'
|
||||
'0009' '0001000101' '00160000')
|
||||
sock.sendall(hello)
|
||||
data = sock.recv(4096)
|
||||
sock.close()
|
||||
idx = data.find(b'\x00\xae')
|
||||
self.assertGreater(idx, 0, data.hex())
|
||||
ext_total = int.from_bytes(data[idx + 3:idx + 5], 'big')
|
||||
ext = data[idx + 5:idx + 5 + ext_total]
|
||||
seen = set()
|
||||
off = 0
|
||||
while off + 4 <= len(ext):
|
||||
etype = int.from_bytes(ext[off:off + 2], 'big')
|
||||
elen = int.from_bytes(ext[off + 2:off + 4], 'big')
|
||||
seen.add(etype)
|
||||
off += 4 + elen
|
||||
self.assertNotIn(0x0016, seen) # encrypt_then_mac
|
||||
self.assertNotIn(0x0023, seen) # session_ticket (no resumption)
|
||||
finally:
|
||||
srv.stop()
|
||||
|
||||
def test_command_then_close(self):
|
||||
def responder(method, target, headers, body):
|
||||
if b'cmd=1' in target.encode():
|
||||
return (200, {'X-Admin-Protocol': scp81.GP_PROTOCOL,
|
||||
'X-Admin-Next-URI': '/server/adminagent?cmd=2',
|
||||
'Content-Type': scp81.GP_CT_COMMAND}, b'\x80\x01\x00')
|
||||
return 204, {'X-Admin-Protocol': scp81.GP_PROTOCOL}, b''
|
||||
|
||||
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, responder=responder,
|
||||
keep_alive=True)
|
||||
try:
|
||||
tls = self._connect(srv)
|
||||
tls.sendall(b'POST /server/adminagent?cmd=1 HTTP/1.1\r\n\r\n')
|
||||
reply = self._read_http(tls)
|
||||
self.assertIn(b'X-Admin-Next-URI: /server/adminagent?cmd=2', reply)
|
||||
head, _, body = reply.partition(b'\r\n\r\n')
|
||||
self.assertIn(b'Content-Length: 3', head)
|
||||
self.assertEqual(body[:3], b'\x80\x01\x00')
|
||||
tls.sendall(b'POST /server/adminagent?cmd=2 HTTP/1.1\r\n'
|
||||
b'X-Admin-Script-Status: ok\r\n'
|
||||
b'Content-Length: 3\r\n\r\n\x80\x02\x00')
|
||||
reply = self._read_http(tls)
|
||||
self.assertTrue(reply.startswith(b'HTTP/1.1 204 No Content'))
|
||||
tls.close()
|
||||
finally:
|
||||
srv.stop()
|
||||
|
||||
|
||||
class ScriptResponderTest(unittest.TestCase):
|
||||
"""RAM over HTTP command scripting (TS 102 226 5.2, GP 4.4.2)."""
|
||||
|
||||
def setUp(self):
|
||||
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
|
||||
server._SCP81_SCRIPT_SENT = 0
|
||||
server._SCP81_SCRIPT_RESULTS = []
|
||||
|
||||
def tearDown(self):
|
||||
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
|
||||
server._SCP81_SCRIPT_SENT = 0
|
||||
server._SCP81_SCRIPT_RESULTS = []
|
||||
|
||||
def test_command_body_is_indefinite_scripting_template(self):
|
||||
# Reference admin server: AE 80 22 <len> <apdu> 00 00
|
||||
body = server._scp81_command_body('80CAFF2100')
|
||||
self.assertEqual(body.hex(), 'ae80220580caff210000' + '00')
|
||||
self.assertEqual(body[0:2], b'\xae\x80')
|
||||
self.assertEqual(body[2], 0x22)
|
||||
self.assertEqual(body[3], 5)
|
||||
|
||||
def test_command_body_definite_scripting_template(self):
|
||||
# AA <len> 22 <len> <apdu>
|
||||
body = server._scp81_command_body('80CAFF2100', definite=True)
|
||||
self.assertEqual(body.hex(), 'aa072205' + '80caff2100')
|
||||
|
||||
def test_command_body_cr_set_c_apdu_tag(self):
|
||||
# Some cards use the comprehension-required tag variant (A2)
|
||||
self.assertEqual(server._scp81_command_body('80CA004500', definite=True, cr_tag=True).hex(),
|
||||
'aa07a20580ca004500')
|
||||
self.assertEqual(server._scp81_command_body('80CA004500', cr_tag=True).hex(),
|
||||
'ae80a20580ca004500' + '0000')
|
||||
|
||||
def test_parse_response_indefinite(self):
|
||||
# AF 80 (80 01 01) (23 04 93 05 90 00) 00 00
|
||||
body = bytes.fromhex('af80' '800101' '2304' '9305' '9000' '0000')
|
||||
count, rapdus = server._scp81_parse_response(body)
|
||||
self.assertEqual(count, 1)
|
||||
self.assertEqual(rapdus, [(bytes.fromhex('9305'), '9000')])
|
||||
|
||||
def test_parse_response_definite(self):
|
||||
body = bytes.fromhex('ab08' '800101' '2303' '019000')
|
||||
count, rapdus = server._scp81_parse_response(body)
|
||||
self.assertEqual(count, 1)
|
||||
self.assertEqual(rapdus, [(bytes.fromhex('01'), '9000')])
|
||||
|
||||
def test_decode_memory(self):
|
||||
rapdu = bytes.fromhex('ff210c' '810102' '8203' '00f0a0' '8302' '0800')
|
||||
self.assertEqual(server._scp81_decode_memory(rapdu),
|
||||
{'applets': 2, 'free_nv': 0xf0a0, 'free_volatile': 0x0800})
|
||||
self.assertIsNone(server._scp81_decode_memory(bytes.fromhex('9000')))
|
||||
|
||||
def test_responder_sends_script_then_204(self):
|
||||
# Use an explicit two-command script, independent of the presets.
|
||||
server._SCP81_SCRIPT = ['80CAFF2100', '80F22002024F0000']
|
||||
logs = []
|
||||
old_bip = server._BIP
|
||||
server._BIP = mock.Mock()
|
||||
server._BIP.log = lambda kind, **f: logs.append((kind, f))
|
||||
try:
|
||||
# First POST: no script status -> first APDU + Next-URI
|
||||
status, headers, body = server._scp81_script_responder(
|
||||
'POST', '/api/scp81', {}, b'')
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(headers['X-Admin-Next-URI'], '/api/scp81?req=1')
|
||||
self.assertEqual(headers['Content-Type'],
|
||||
'application/vnd.globalplatform.card-content-mgt;version=1.0')
|
||||
self.assertEqual(body.hex(), 'ae80220580caff21000000')
|
||||
# Response to it -> second APDU
|
||||
status, headers, body = server._scp81_script_responder(
|
||||
'POST', '/api/scp81?step=1', {'x-admin-script-status': 'ok'},
|
||||
bytes.fromhex('af80' '800101' '2304' '93059000' '0000'))
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(body.hex(), 'ae802208' + '80f22002024f0000' + '0000')
|
||||
# Last response -> session closed with 204
|
||||
status, headers, body = server._scp81_script_responder(
|
||||
'POST', '/api/scp81?step=2', {'x-admin-script-status': 'ok'},
|
||||
bytes.fromhex('af80' '800101' '2303' '5f9000' '0000'))
|
||||
self.assertEqual(status, 204)
|
||||
kinds = [k for k, _ in logs]
|
||||
self.assertIn('script-send', kinds)
|
||||
self.assertIn('script-rapdu', kinds)
|
||||
self.assertIn('script-done', kinds)
|
||||
self.assertEqual(len(server._SCP81_SCRIPT_RESULTS), 2)
|
||||
finally:
|
||||
server._BIP = old_bip
|
||||
|
||||
def test_responder_reports_script_failure(self):
|
||||
old_bip = server._BIP
|
||||
server._BIP = mock.Mock()
|
||||
logs = []
|
||||
server._BIP.log = lambda kind, **f: logs.append((kind, f))
|
||||
try:
|
||||
server._scp81_script_responder('POST', '/x', {}, b'')
|
||||
status, headers, body = server._scp81_script_responder(
|
||||
'POST', '/x', {'x-admin-script-status': 'security-error'}, b'')
|
||||
self.assertEqual(status, 200) # script continues with the next APDU
|
||||
self.assertIn(('script-status', {'index': 1, 'status': 'security-error'}), logs)
|
||||
finally:
|
||||
server._BIP = old_bip
|
||||
|
||||
|
||||
class BipControlTest(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
server._scp81_bip_control({'action': 'stop'})
|
||||
server._SCP81_PSK = {}
|
||||
|
||||
def test_tls_mode_requires_psk(self):
|
||||
server._SCP81_PSK = {}
|
||||
resp = server._scp81_bip_control({'action': 'start', 'mode': 'tls'})
|
||||
self.assertFalse(resp['ok'])
|
||||
self.assertIn('psk_hex', resp['error'])
|
||||
|
||||
def test_tls_mode_starts_and_reports_status(self):
|
||||
resp = server._scp81_bip_control({'action': 'start', 'mode': 'tls',
|
||||
'host': '127.0.0.1', 'port': 0,
|
||||
'psk_hex': '0011 2233', 'psk_identity': 'id-1'})
|
||||
self.assertTrue(resp['ok'], resp)
|
||||
listener = resp['listener']
|
||||
self.assertEqual(listener['mode'], 'tls')
|
||||
self.assertEqual(listener['psk_identity'], 'id-1')
|
||||
self.assertIsNone(listener['identity_seen'])
|
||||
self.assertTrue(resp['bip']['enabled'])
|
||||
# the key never leaves the server
|
||||
self.assertNotIn('psk_hex', listener)
|
||||
|
||||
def test_unknown_mode_rejected(self):
|
||||
resp = server._scp81_bip_control({'action': 'start', 'mode': 'nope'})
|
||||
self.assertFalse(resp['ok'])
|
||||
self.assertIn('unsupported mode', resp['error'])
|
||||
|
||||
|
||||
class DataAvailableTest(unittest.TestCase):
|
||||
def _channel(self, cid=1, rx=b'\x16\x03\x03'):
|
||||
ch = types.SimpleNamespace(id=cid, rx=bytearray(rx), peer_closed=False)
|
||||
return ch
|
||||
|
||||
def _state(self, event_list=(0x09,)):
|
||||
sent = []
|
||||
|
||||
class Tp:
|
||||
def send_apdu(self, apdu):
|
||||
sent.append(apdu)
|
||||
return '', '9000'
|
||||
|
||||
scc = types.SimpleNamespace(cat_cla='80', _tp=Tp())
|
||||
ref = types.SimpleNamespace(scc=scc, event_list=list(event_list),
|
||||
stk_pending=None)
|
||||
return ref, sent
|
||||
|
||||
def test_data_available_event(self):
|
||||
ref, sent = self._state()
|
||||
with mock.patch.object(server, '_server_ref', ref):
|
||||
with mock.patch.object(server, '_CARD_CONNECTED', True):
|
||||
ok = server._bip_data_available(self._channel())
|
||||
self.assertTrue(ok)
|
||||
# ENVELOPE(Event Download - Data available): channel 1 established,
|
||||
# 3 bytes waiting (B8 status + B7 length)
|
||||
self.assertEqual(sent, ['80c2000010d60e99010982028281b8028100b70103'])
|
||||
|
||||
def test_skipped_without_subscription(self):
|
||||
ref, sent = self._state(event_list=[0x03, 0x0A])
|
||||
with mock.patch.object(server, '_server_ref', ref):
|
||||
with mock.patch.object(server, '_CARD_CONNECTED', True):
|
||||
ok = server._bip_data_available(self._channel())
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(sent, [])
|
||||
|
||||
def test_skipped_while_menu_pending(self):
|
||||
ref, sent = self._state()
|
||||
ref.stk_pending = {'type': 'select_item'}
|
||||
with mock.patch.object(server, '_server_ref', ref):
|
||||
with mock.patch.object(server, '_CARD_CONNECTED', True):
|
||||
ok = server._bip_data_available(self._channel())
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(sent, [])
|
||||
|
||||
def test_monitor_notifies_once_per_arrival(self):
|
||||
peer = socket.socket()
|
||||
peer.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
peer.bind(('127.0.0.1', 0))
|
||||
peer.listen(1)
|
||||
bip = server.httpota.BipTerminal()
|
||||
seen = []
|
||||
bip.on_data = lambda ch: (seen.append((ch.id, len(ch.rx)))) or True
|
||||
try:
|
||||
bip.enable('127.0.0.1', peer.getsockname()[1])
|
||||
cid, err = bip.open('10.0.0.1', 1, 512)
|
||||
self.assertIsNone(err)
|
||||
conn, _ = peer.accept()
|
||||
conn.sendall(b'HELLO')
|
||||
deadline = time.time() + 3
|
||||
while time.time() < deadline and not seen:
|
||||
time.sleep(0.05)
|
||||
time.sleep(0.6) # several monitor ticks
|
||||
self.assertEqual(seen, [(cid, 5)])
|
||||
finally:
|
||||
bip.disable()
|
||||
peer.close()
|
||||
|
||||
def test_remaining_bytes_are_re_announced(self):
|
||||
# The live card waits for a fresh Data available event for the bytes
|
||||
# left after a partial RECEIVE DATA (announced in the TR length TLV).
|
||||
peer = socket.socket()
|
||||
peer.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
peer.bind(('127.0.0.1', 0))
|
||||
peer.listen(1)
|
||||
bip = server.httpota.BipTerminal()
|
||||
seen = []
|
||||
bip.on_data = lambda ch: (seen.append(len(ch.rx))) or True
|
||||
try:
|
||||
bip.enable('127.0.0.1', peer.getsockname()[1])
|
||||
cid, err = bip.open('10.0.0.1', 1, 512)
|
||||
self.assertIsNone(err)
|
||||
conn, _ = peer.accept()
|
||||
conn.sendall(b'0123456789')
|
||||
deadline = time.time() + 3
|
||||
while time.time() < deadline and not seen:
|
||||
time.sleep(0.05)
|
||||
self.assertEqual(seen, [10])
|
||||
self.assertEqual(bip.receive(cid, 4), b'0123')
|
||||
deadline = time.time() + 3
|
||||
while time.time() < deadline and len(seen) < 2:
|
||||
time.sleep(0.05)
|
||||
self.assertEqual(seen[1], 6)
|
||||
finally:
|
||||
bip.disable()
|
||||
peer.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
class WaitDrainedTest(unittest.TestCase):
|
||||
def test_wait_drained_matches_channel_by_port(self):
|
||||
ch = types.SimpleNamespace(rx=bytearray(), sock=types.SimpleNamespace(
|
||||
getsockname=lambda: ('127.0.0.1', 40001)))
|
||||
old = server._BIP
|
||||
server._BIP = types.SimpleNamespace(channels={1: ch})
|
||||
try:
|
||||
# unknown port / gone channel -> immediate
|
||||
self.assertIsNone(server._scp81_wait_drained(('127.0.0.1', 40002)))
|
||||
finally:
|
||||
server._BIP = old
|
||||
|
||||
class WaitDrainedSlowTest(unittest.TestCase):
|
||||
def test_wait_drained_waits_for_card_fetch(self):
|
||||
import threading, time as _time
|
||||
ch = types.SimpleNamespace(rx=bytearray(), sock=types.SimpleNamespace(
|
||||
getsockname=lambda: ('127.0.0.1', 40003)))
|
||||
|
||||
def feed():
|
||||
_time.sleep(0.15)
|
||||
ch.rx.extend(b'response-bytes') # pump picks up the response
|
||||
_time.sleep(0.25)
|
||||
ch.rx.clear() # card fetches everything
|
||||
|
||||
old = server._BIP
|
||||
server._BIP = types.SimpleNamespace(channels={1: ch})
|
||||
th = threading.Thread(target=feed)
|
||||
th.start()
|
||||
t0 = _time.time()
|
||||
try:
|
||||
server._scp81_wait_drained(('127.0.0.1', 40003))
|
||||
finally:
|
||||
server._BIP = old
|
||||
th.join()
|
||||
self.assertGreater(_time.time() - t0, 0.3)
|
||||
|
||||
class KeylogTest(unittest.TestCase):
|
||||
def test_keylog_filename_set(self):
|
||||
import tempfile, os
|
||||
fd, path = tempfile.mkstemp(prefix='scp81keys')
|
||||
os.close(fd)
|
||||
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, keylog=path)
|
||||
try:
|
||||
self.assertEqual(srv.ctx.keylog_filename, path)
|
||||
finally:
|
||||
srv.stop()
|
||||
os.unlink(path)
|
||||
|
||||
class ConnHeaderTest(unittest.TestCase):
|
||||
def test_conn_header_none_omits_connection(self):
|
||||
import types
|
||||
seen = {}
|
||||
def responder(method, target, headers, body):
|
||||
return 204, {}, b''
|
||||
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, responder=responder,
|
||||
keep_alive=True, conn_header='none')
|
||||
try:
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
ctx.set_ciphers('PSK')
|
||||
ctx.set_psk_client_callback(lambda hint: (b'id', PSK))
|
||||
raw = socket.create_connection(('127.0.0.1', srv.port), timeout=5)
|
||||
tls = ctx.wrap_socket(raw, server_hostname='x')
|
||||
tls.sendall(b'POST /api/scp81 HTTP/1.1\r\n\r\n')
|
||||
data = tls.recv(4096)
|
||||
self.assertNotIn(b'Connection:', data)
|
||||
tls.close()
|
||||
finally:
|
||||
srv.stop()
|
||||
|
||||
|
||||
class TargetedAppTest(unittest.TestCase):
|
||||
def test_targeted_app_header(self):
|
||||
server._SCP81_SCRIPT = ['80CAFF2100']
|
||||
server._SCP81_SCRIPT_SENT = 0
|
||||
server._SCP81_TARGETED_APP = '//aid/A000000151000000'
|
||||
try:
|
||||
status, headers, body = server._scp81_script_responder(
|
||||
'POST', '/api/scp81', {}, b'')
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(headers['X-Admin-Targeted-Application'],
|
||||
'//aid/A000000151000000')
|
||||
finally:
|
||||
server._SCP81_TARGETED_APP = None
|
||||
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
|
||||
server._SCP81_SCRIPT_SENT = 0
|
||||
|
||||
def test_apache_headers(self):
|
||||
server._SCP81_SCRIPT = ['80CAFF2100']
|
||||
server._SCP81_SCRIPT_SENT = 0
|
||||
server._SCP81_APACHE_HEADERS = True
|
||||
server._SCP81_CHUNKED = False
|
||||
try:
|
||||
status, headers, body = server._scp81_script_responder(
|
||||
'POST', '/api/scp81', {}, b'')
|
||||
self.assertEqual(list(headers)[:4],
|
||||
['Date', 'Server', 'X-Powered-By', 'X-Admin-Protocol'])
|
||||
self.assertEqual(headers['Content-Length'], str(len(body)))
|
||||
out = scp81.build_http_response(status, 'OK', headers, body)
|
||||
self.assertLess(out.index(b'Content-Length'),
|
||||
out.index(b'Content-Type'))
|
||||
finally:
|
||||
server._SCP81_APACHE_HEADERS = False
|
||||
server._SCP81_CHUNKED = False
|
||||
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
|
||||
server._SCP81_SCRIPT_SENT = 0
|
||||
|
||||
def test_chunked_apache_has_no_content_length(self):
|
||||
# The reference (RAM/HTTPOTA_test5.pcap, decryptable) sends chunked
|
||||
# without Content-Length, Transfer-Encoding before Content-Type.
|
||||
server._SCP81_SCRIPT = ['80CAFF2100']
|
||||
server._SCP81_SCRIPT_SENT = 0
|
||||
server._SCP81_APACHE_HEADERS = True
|
||||
server._SCP81_CHUNKED = True
|
||||
try:
|
||||
status, headers, body = server._scp81_script_responder(
|
||||
'POST', '/api/scp81', {}, b'')
|
||||
self.assertNotIn('Content-Length', headers)
|
||||
self.assertEqual(headers['Transfer-Encoding'], 'chunked')
|
||||
self.assertLess(list(headers).index('Transfer-Encoding'),
|
||||
list(headers).index('Content-Type'))
|
||||
out = scp81.build_http_response(status, 'OK', headers, body,
|
||||
chunked=True, connection=None)
|
||||
self.assertNotIn(b'Content-Length', out)
|
||||
self.assertEqual(out.count(b'Transfer-Encoding'), 1)
|
||||
finally:
|
||||
server._SCP81_APACHE_HEADERS = False
|
||||
server._SCP81_CHUNKED = False
|
||||
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
|
||||
server._SCP81_SCRIPT_SENT = 0
|
||||
|
||||
def test_exact_wire_bodies_from_reference_log(self):
|
||||
# De-chunked bodies captured in adminserver.log (2019-09-05).
|
||||
count, rapdus = server._scp81_parse_response(bytes.fromhex(
|
||||
'af802319e3154f08a0000001510000009f70010fc5039afe80ea0090000000'))
|
||||
self.assertEqual(len(rapdus), 1)
|
||||
self.assertEqual(rapdus[0][1], '9000')
|
||||
self.assertTrue(rapdus[0][0].startswith(b'\xe3\x15'))
|
||||
count, rapdus = server._scp81_parse_response(
|
||||
bytes.fromhex('af8023026a880000'))
|
||||
self.assertEqual(rapdus[0][1], '6A88')
|
||||
# A status-only POST (no body, e.g. unknown-application) parses empty.
|
||||
self.assertEqual(server._scp81_parse_response(b''), (0, []))
|
||||
Reference in New Issue
Block a user