v1.9.17: fix explore card — chain GET STATUS + GET RESPONSE inside SCP80

Key fix: re-add chained C0000000 (GET RESPONSE Le=00) in paginate() so the
full GP APDU inside SCP80 is 80F2<p1>p2024F0000C0000000, matching the
working tool. The card's SCP80 layer executes both commands internally
(GET STATUS → 61XX → GET RESPONSE) and puts the final 9000 + data in
the PoR.

Also: server logging improvements (RAM RESPONSE-PACKET label, no truncation
of FETCH/PoR hex), docs for /api/ram-install endpoint, minor test fix.
This commit is contained in:
2026-08-30 14:13:13 +03:00
parent 32985a2951
commit 54dfb2f6b6
6 changed files with 654 additions and 24 deletions
+45 -16
View File
@@ -18,7 +18,7 @@ from osmocom.construct import GsmOrUcs2Adapter
from osmocom.tlv import BER_TLV_IE
VERSION = '1.9.12'
VERSION = '1.9.17'
# Static file serving (the PWA lives in <repo>/frontend, served by this server
@@ -344,6 +344,7 @@ def _decode_por(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex, respon
# Try ExpandedRemoteResponse first (TS 102 226 §5.2.2)
if res.response_status == 'por_ok' and len(res['secured_data']):
expanded_response_data = ''
try:
from construct import Struct, Int8ub, Bytes, GreedyBytes, Optional, Array, this
ExpandedRemoteResponse = Struct(
@@ -381,17 +382,20 @@ def _decode_por(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex, respon
response_data['is_first'] = resp.chaining_context.is_first == 0x01
response_data['is_last'] = resp.chaining_context.is_last == 0x01
out['responses'].append(response_data)
if expanded.response_count > 0 and expanded.responses[0].response_data:
expanded_response_data = b2h(expanded.responses[0].response_data).upper()
except Exception:
# Fallback to CompactRemoteResp
if dec is not None:
out['response_type'] = 'compact'
out['decoded'] = {
'number_of_commands': dec.number_of_commands,
'last_status_word': str(dec.last_status_word),
'last_response_data': str(dec.last_response_data),
}
else:
out['response_type'] = 'none'
pass
if dec is not None:
out['response_type'] = 'compact'
# Use compact parser's last_response_data; expanded parser gives wrong results for compact format
out['decoded'] = {
'number_of_commands': dec.number_of_commands,
'last_status_word': str(dec.last_status_word),
'last_response_data': str(dec.last_response_data),
}
else:
out['response_type'] = 'none'
elif dec is not None:
out['response_type'] = 'compact'
out['decoded'] = {
@@ -1017,7 +1021,7 @@ def _handle_proactive_chain(scc, sw91, on_fetch=None):
while sw.startswith('91'):
fetch_len = int(sw[2:], 16) if len(sw) == 4 else 0x100
rv = scc._tp.send_apdu('%s120000%02x' % (scc.cat_cla, fetch_len))
sys.stderr.write('FETCH(%s): %s -> %s\n' % (fetch_len, rv[0][:80] if rv[0] else '(none)', rv[1]))
sys.stderr.write('FETCH(%s): %s -> %s\n' % (fetch_len, rv[0] if rv[0] else '(none)', rv[1]))
fdata, sw = rv[0], rv[1]
raw = bytes.fromhex(fdata) if fdata else None
action = None
@@ -1812,6 +1816,7 @@ class PysimHandler(BaseHTTPRequestHandler):
body = self._read_body()
self._log_req(body)
sp = body.get('sp', '')
apdu = body.get('apdu', '')
scc = self.server.scc
if not scc:
self._send_json({'error': _err('reader_not_init', lang)}, 503)
@@ -1819,7 +1824,22 @@ class PysimHandler(BaseHTTPRequestHandler):
return
include_cpi = body.get('includeCpi', True)
try:
sp_bytes = bytes.fromhex(sp)
if apdu:
# RAM operation: SCP80-wrap the raw GP command
spi1 = body.get('spi1', '16')
spi2 = body.get('spi2', '01')
kic = body.get('kic', '25')
kid = body.get('kid', '25')
tar = body.get('tar', '000000')
cntr = body.get('cntr', '')
kic_key = body.get('kicKey', '')
kid_key = body.get('kidKey', '')
sp_hex, _ = _ota_reference(spi1, spi2, kic, kid, tar, cntr, apdu, kic_key, kid_key)
sp_bytes = bytes.fromhex(sp_hex)
else:
# Regular SCP80: use pre-built secured packet
sp_hex = sp
sp_bytes = bytes.fromhex(sp_hex)
spi2_val = int(body.get('spi2', '00'), 16)
por_in_submit = bool(spi2_val & 0x20)
submit_handler = None
@@ -1836,12 +1856,13 @@ class PysimHandler(BaseHTTPRequestHandler):
body.get('spi1', ''), body.get('spi2', ''), body.get('kic', ''),
body.get('kid', ''), body.get('tar', ''), body.get('cntr', ''),
len(sp_bytes), total))
sys.stderr.write('RAM C-APDU: %s\n' % apdu if apdu else sp)
sys.stderr.write('RAM SECURED-PACKET: %s\n' % sp_hex)
last_data = None
last_sw = None
for i, chunk in enumerate(chunks):
tpdu = _build_sms_tpdu(chunk.hex(), total, i + 1, oa_number=self.server.sms_oa,
include_cpi=include_cpi) if total > 1 else _build_sms_tpdu(sp, oa_number=self.server.sms_oa,
include_cpi=include_cpi)
include_cpi=include_cpi)
data, sw = _send_envelope(tpdu, scc, sm_sc=self.server.sms_sc, submit_handler=submit_handler)
last_data = data
last_sw = sw
@@ -1862,17 +1883,25 @@ class PysimHandler(BaseHTTPRequestHandler):
por = _decode_por(body.get('spi1', ''), body.get('spi2', ''), body.get('kic', ''),
body.get('kid', ''), body.get('cntr', ''), body.get('kicKey', ''),
body.get('kidKey', ''), por_hex)
# Check for SPI2=0x21 (PoR required) but got 9000 with no PoR → card refuses PoR
is_ram = bool(apdu)
por_required = bool(spi2_val & 0x01)
no_por_received = not por_hex and not (submit_handler and submit_handler.submit_tpdu_hex)
if is_ram and por_required and last_sw == '9000' and no_por_received:
sys.stderr.write('WARNING: Card refused to return PoR - ENVELOPE returned 9000 with no response data\n')
sys.stderr.write('RAM RESPONSE-PACKET: %s\n' % (por_hex if por_hex else 'empty'))
if por:
resp['por'] = por
extra = ''
if por.get('decoded'):
extra = ' (compact: %s cmd, last SW %s)' % (por['decoded'].get('number_of_commands', '?'),
por['decoded'].get('last_status_word', '?'))
sys.stderr.write('RAM R-APDU: %s\n' % por['decoded'].get('last_response_data', ''))
sys.stderr.write('OTA PoR[%s]: status=%s TAR=%s CNTR=%s PCNTR=%s RPL=%s RHL=%s%s\n' % (
por_src, por.get('response_status'), por.get('tar'), por.get('cntr'),
por.get('pcntr'), por.get('rpl'), por.get('rhl'), extra))
elif por_hex:
sys.stderr.write('OTA PoR[%s]: undecodable raw=%s\n' % (por_src, str(por_hex)[:64]))
sys.stderr.write('OTA PoR[%s]: undecodable raw=%s\n' % (por_src, str(por_hex)))
else:
sys.stderr.write('OTA PoR[%s]: none\n' % por_src)
finally: