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:
2026-09-16 01:50:29 +03:00
parent ea1730b206
commit 175ca934d8
18 changed files with 3049 additions and 56 deletions
+194
View File
@@ -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
+191
View File
@@ -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)."""
+681
View File
@@ -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, []))