transport: stop the T=0 layer from breaking GP 6310

TS 102 221 section 7.3.1.1.4 clause 4b lets the card answer a case #4
command TPDU with a 62xx/63xx warning, upon which the terminal sends a
dummy GET RESPONSE to obtain the 61xx that announces the response length.
__send_apdu_T0() applies that unconditionally, to the status word that
terminates a GET RESPONSE...

That is not "redundant", as per GPC v2.3.1 section 11.4.3.2 table 11-38
GP GET STATUS (80 F2) answers 6310 "more data available", meaning reissue
the command as get next occurrence(s) with P2 bit 1 set (11.4.2.2 table
11-34), so for example a paginated registry
listing exchange looks like this:

  84f22000024f00  =>            61e4    first match, e4 bytes waiting
  84c00000e4      => <page 1>   6310    more matches pending
  84c0000000      =>            6982    <- unsolicited, card rejects it

The card rejects the unsolicited GET RESPONSE with 6982, our SCP02
session breaks, and the next command fail with 6985. The 6310 never
reaches ADF_SD.get_status() either, so the pagination loop exits after
page 1 and prints a sliently truncated listing. Observed with a SJA5.

Fix by turning clause 4b würgaround into what it should be: a one shot
reaction to the SW returned for the command TPDU.

While at it, stop ADF_SD.get_status() from silently returning a truncated
registry: it treated every status word other than 6310 as
"nothing more to report". 6A88 is now the explicit empty result and anything
else raises, so a partial listing can no longer silently pass.

Adds unit tests for all of that so we dont break basic T0 things.

Change-Id: I10f8afa8dd5623a49a6a0e7132607b3a1fad2d8c
This commit is contained in:
Eric Wild
2026-09-23 17:51:04 +02:00
parent da94468c5f
commit 515925228d
5 changed files with 286 additions and 10 deletions
+4 -3
View File
@@ -38,15 +38,16 @@ class SwMatchError(Exception):
"""Raised when an operation specifies an expected SW but the actual SW from
the card doesn't match."""
def __init__(self, sw_actual: str, sw_expected: str, rs=None):
def __init__(self, sw_actual: str, sw_expected, rs=None):
"""
Args:
sw_actual : the SW we actually received from the card (4 hex digits)
sw_expected : the SW we expected to receive from the card (4 hex digits)
sw_expected : the SW we expected to receive from the card (4 hex digits),
or a list of acceptable ones
rs : interpreter class to convert SW to string
"""
self.sw_actual = sw_actual
self.sw_expected = sw_expected
self.sw_expected = '/'.join(sw_expected) if isinstance(sw_expected, (list, tuple)) else sw_expected
self.rs = rs
@property
+12 -5
View File
@@ -737,22 +737,29 @@ class ADF_SD(CardADF):
subset_hex = b2h(build_construct(StatusSubset, subset))
aid = ApplicationAID(decoded=aid_search_qualifier)
cmd_data = aid.to_tlv() + h2b('5c054f9f70c5cc')
p2 = 0x02 # TLV format according to Table 11-36
p2 = 0x02 # GPC v2.3.1 11.4.2.2 table 11-34, b2: response data structure per table 11-36
grd_list = []
while True:
hdr = "80F2%s%02x%02x" % (subset_hex, p2, len(cmd_data))
data, sw = self._cmd.lchan.scc.send_apdu(hdr + b2h(cmd_data) + "00")
if sw == '6a88':
# "Referenced data not found": nothing (more) matches the requested subset and AID
# search qualifier. That is empty, not error?
return grd_list
if sw not in ['9000', '6310']:
# Never return a silently truncated registry
raise SwMatchError(sw, ['9000', '6310'])
remainder = h2b(data)
while len(remainder):
# tlv sequence, each element is one GpRegistryRelatedData()
grd = GpRegistryRelatedData()
_dec, remainder = grd.from_tlv(remainder)
grd_list.append(grd)
if sw != '6310':
if sw == '9000':
return grd_list
else:
p2 |= 0x01
return grd_list
# 6310 = more data available, table 11-38: reissue as get next occurrence(s), b1 of
# table 11-34. Keeps b2 unchanged.
p2 |= 0x01
set_status_parser = argparse.ArgumentParser()
set_status_parser.add_argument('scope', choices=list(SetStatusScope.ksymapping.values()),
+14 -1
View File
@@ -333,6 +333,18 @@ class LinkBaseTpdu(LinkBase):
# correctly the Le byte (usually 0x00) must be present, is often forgotten. To avoid problems with
# legacy scripts that use raw APDU strings, we will still loosely apply GET RESPONSE based on what
# the status word indicates. Unless the user explicitly enables the strict mode (set apdu_strict true)
#
# The dummy GET RESPONSE of clause 4b (see below) is one shot: it turns a warning SW into the 61xx
# that announces the response length. It is only ever a valid reaction to the SW returned for the
# _command_ TPDU. Once a response has been fetched there is nothing left to announce, so a warning
# SW is the final result of the command and has to be passed on to the caller unmodified.
#
# This matters because the 62xx/63xx range is not exclusive to ETSI TS 102 221.
# GPC v2.3.1 section 11.4.3.2 table 11-38 GP GET STATUS (80 F2) answers
# 6310 "more data available", meaning "reissue with P2 bit 1 set" as per section 11.4.2.2 table 11-34
# rather than "response data is waiting". Trying a random GET RESPONSE at that point
# makes the card answer 6982 and tears down the whole SCP session and following commands fail with 6985.
dummy_gr_allowed = not data
while True:
if sw in ['9000', '9100']:
# A status word of 9000 (or 9100 in case there is pending data from a proactive SIM command)
@@ -345,7 +357,7 @@ class LinkBaseTpdu(LinkBase):
# word. (see also ETSI TS 102 221, section 7.3.1.1.4, clause 4a and 3GPP TS 51.011 9.4.1 and
# ISO/IEC 7816-4, Table 5)
le_gr = sw[2:4]
elif sw[0:2] in ['62', '63']:
elif sw[0:2] in ['62', '63'] and dummy_gr_allowed:
# There are corner cases (status word is 62xx or 63xx) where the UICC/eUICC/SIM asks us
# to send a dummy GET RESPONSE command. We send a GET RESPONSE command with a length of 0.
# (see also ETSI TS 102 221, section 7.3.1.1.4, clause 4b and ETSI TS 151 011, section 9.4.1)
@@ -360,6 +372,7 @@ class LinkBaseTpdu(LinkBase):
data_gr, sw = self.send_tpdu(tpdu_gr)
log.debug("T0: GET RESPONSE TPDU: %s => %s %s", tpdu_gr, data_gr or "(no data)", sw or "(no status word)")
data += data_gr
dummy_gr_allowed = False
if sw[0:2] == '6c':
# SW1=6C: ETSI TS 102 221 Table 7.1: Procedure byte coding
tpdu_gr = prev_tpdu[0:8] + sw[2:4]
+68
View File
@@ -713,5 +713,73 @@ class Load_ChunkLen_Test(unittest.TestCase):
self.assertLessEqual(wrapped[4], 255)
class _FakeScc:
"""mock lchan.scc: replays scripted (data, sw) pairs + records the APDUs sent."""
def __init__(self, responses):
self._responses = list(responses)
self.sent = []
def send_apdu(self, apdu):
self.sent.append(apdu.lower())
if not self._responses:
raise AssertionError('get_status sent unexpected APDU: %s' % apdu)
return self._responses.pop(0)
class GetStatus_Pagination_Test(unittest.TestCase):
"""GPC v2.3.1 section 11.4.3.2 table 11-38 GET STATUS pagination test
Card answers 6310 when further matches are pending; command reissued with
P2 bit 1 "next occurrence" set. Tied to T=0 handling pySim/transport, which
used to swallow that 6310 and replied with GET RESPONSE, so page 2 was never fetched."""
ENTRY_1 = 'e3074f05a000000151'
ENTRY_2 = 'e3074f05a000000152'
def _sd(self, responses):
scc = _FakeScc(responses)
cmd = type('_Cmd', (), {'lchan': type('_Lchan', (), {'scc': scc})()})()
# cmd2 strikes again, CommandSet exposes _cmd as a read only property, needs shadowing
_SD = type('_SD', (ADF_SD.AddlShellCommands,), {'_cmd': cmd})
return _SD.__new__(_SD), scc
def _aids(self, grd_list):
return [b2h(grd.to_dict()['gp_registry_related_data'][0]['application_aid']) for grd in grd_list]
def test_single_page(self):
sd, scc = self._sd([(self.ENTRY_1, '9000')])
grd_list = sd.get_status('applications')
self.assertEqual(scc.sent, ['80f24002094f005c054f9f70c5cc00'])
self.assertEqual(self._aids(grd_list), ['a000000151'])
def test_two_pages(self):
"""6310 -> reissue with P2 bit 1 set -> 9000, both pages in result"""
sd, scc = self._sd([(self.ENTRY_1, '6310'), (self.ENTRY_2, '9000')])
grd_list = sd.get_status('applications')
self.assertEqual(scc.sent, ['80f24002094f005c054f9f70c5cc00',
'80f24003094f005c054f9f70c5cc00'])
self.assertEqual(self._aids(grd_list), ['a000000151', 'a000000152'])
def test_three_pages_keep_p2_next_occurrence(self):
sd, scc = self._sd([(self.ENTRY_1, '6310'), (self.ENTRY_2, '6310'), (self.ENTRY_1, '9000')])
grd_list = sd.get_status('applications')
self.assertEqual([a[6:8] for a in scc.sent], ['02', '03', '03'])
self.assertEqual(len(grd_list), 3)
def test_no_match_returns_empty(self):
"""6A88 "referenced data not found" is empty result not failure."""
sd, _scc = self._sd([('', '6a88')])
self.assertEqual(sd.get_status('applications'), [])
def test_unexpected_sw_is_not_silently_truncated(self):
"""partial is not complete result"""
sd, _scc = self._sd([(self.ENTRY_1, '6310'), ('', '6982')])
with self.assertRaises(SwMatchError) as ctx:
sd.get_status('applications')
self.assertEqual(ctx.exception.sw_actual, '6982')
if __name__ == "__main__":
unittest.main()
+188 -1
View File
@@ -23,7 +23,7 @@
import unittest
from osmocom.utils import h2b, b2h
from pySim.cat import ProactiveCommand, CommandDetails, DeviceIdentities, Result
from pySim.transport import ProactiveHandler
from pySim.transport import ProactiveHandler, LinkBaseTpdu
def _send_short_message_pcmd():
@@ -73,5 +73,192 @@ class Test_prepare_response(unittest.TestCase):
self.assertIn('CommandDetails', str(ctx.exception))
class FakeTpduLink(LinkBaseTpdu):
"""mock LinkBaseTpdu that replays a list of (data, sw) responses + records every TPDU that
the T=0 state machine sends. Secretly sending more TPDUs than intended is the error,
designed to test "unsolicited GET RESPONSE" mishaps"""
def __init__(self, responses):
super().__init__()
self._responses = list(responses)
self.sent = []
def send_tpdu(self, tpdu):
self.sent.append(tpdu.lower())
if not self._responses:
raise AssertionError('T=0 layer sent an unpexpected TPDU: %s (total so far: %s)'
% (tpdu, self.sent))
return self._responses.pop(0)
def __str__(self):
return 'FakeTpduLink'
def wait_for_card(self, timeout=None, newcardonly=False):
pass
def connect(self):
pass
def get_atr(self):
return '3b00'
def disconnect(self):
pass
def _reset_card(self):
pass
# GP GET STATUS, wrapped in SCP02 CLA 84, Case #4.
GET_STATUS = '84f22002094f005c054f9f70c5cc' + '00'
GET_STATUS_TPDU = '84f22002094f005c054f9f70c5cc'
# generic #4 SELECT by DF name command
CASE4 = '00a4040c07a0000000871002' + '00'
CASE4_TPDU = '00a4040c07a0000000871002'
class Test_send_apdu_T0(unittest.TestCase):
"""regression tests for the T=0 state machine in LinkBaseTpdu.__send_apdu_T0()"""
def _exchange(self, apdu, responses, strict=True, protocol=0):
link = FakeTpduLink(responses)
link.apdu_strict = strict
link.set_tpdu_format(protocol)
data, sw = link._send_apdu(apdu)
return link, data, sw
#### TS 102 221 section 7.3.1.1 TPDU construction
def test_case1_gets_le_appended(self):
link, data, sw = self._exchange('00200001', [('', '9000')])
self.assertEqual(link.sent, ['0020000100'])
self.assertEqual((data, sw), ('', '9000'))
def test_case3_passed_through_unmodified(self):
apdu = '00200001081122334455667788'
link, _data, sw = self._exchange(apdu, [('', '9000')])
self.assertEqual(link.sent, [apdu])
self.assertEqual(sw, '9000')
def test_case4_le_stripped(self):
link, data, sw = self._exchange(CASE4, [('', '9000')])
self.assertEqual(link.sent, [CASE4_TPDU])
self.assertEqual((data, sw), ('', '9000'))
#### TS 102 221 7.3.1.1.4 4a GP GET RESPONSE for 61xx / 9fxx
def test_61xx_fetches_response(self):
link, data, sw = self._exchange(CASE4, [('', '6103'), ('a1b2c3', '9000')])
self.assertEqual(link.sent, [CASE4_TPDU, '00c0000003'])
self.assertEqual((data, sw), ('a1b2c3', '9000'))
def test_61xx_chained(self):
link, data, sw = self._exchange(CASE4,
[('', '6102'), ('aabb', '6102'), ('ccdd', '9000')])
self.assertEqual(link.sent, [CASE4_TPDU, '00c0000002', '00c0000002'])
self.assertEqual((data, sw), ('aabbccdd', '9000'))
def test_9fxx_fetches_response(self):
link, data, sw = self._exchange(CASE4, [('', '9f04'), ('deadbeef', '9000')])
self.assertEqual(link.sent, [CASE4_TPDU, '00c0000004'])
self.assertEqual((data, sw), ('deadbeef', '9000'))
def test_get_response_inherits_cla(self):
"""GET RESPONSE must reuse CLA of command"""
link, _data, _sw = self._exchange(GET_STATUS, [('', '6102'), ('aabb', '9000')])
self.assertEqual(link.sent, [GET_STATUS_TPDU, '84c0000002'])
def test_9100_terminates(self):
"""9100 is final status word, not fetch trigger"""
link, data, sw = self._exchange(CASE4, [('', '9100')])
self.assertEqual(link.sent, [CASE4_TPDU])
self.assertEqual((data, sw), ('', '9100'))
def test_error_sw_terminates(self):
link, data, sw = self._exchange(CASE4, [('', '6982')])
self.assertEqual(link.sent, [CASE4_TPDU])
self.assertEqual((data, sw), ('', '6982'))
def test_no_status_word_raises(self):
with self.assertRaises(ValueError):
self._exchange(CASE4, [('', None)])
#### TS 102 221 7.3.1.1.4 4b dummy GET RESPONSE
def test_clause_4b_warning_before_data_bootstraps(self):
"""warning SW returned for the _command_ TPDU triggers dummy GET RESPONSE (Le=00)"""
for warn in ('6200', '6281', '62f1', '6300', '63f1'):
with self.subTest(sw=warn):
link, data, sw = self._exchange(CASE4,
[('', warn), ('', '6103'), ('a1b2c3', '9000')])
self.assertEqual(link.sent, [CASE4_TPDU, '00c0000000', '00c0000003'])
self.assertEqual((data, sw), ('a1b2c3', '9000'))
def test_warning_after_data_terminates(self):
"""Once the response has been fetched a warning status word is the final result of the command"""
for warn in ('6281', '6283', '63c2', '6300', '62f1', '63f1', '6310'):
with self.subTest(sw=warn):
link, data, sw = self._exchange(CASE4, [('', '6102'), ('aabb', warn)])
self.assertEqual(link.sent, [CASE4_TPDU, '00c0000002'])
self.assertEqual((data, sw), ('aabb', warn))
def test_no_dummy_get_response_when_command_already_returned_data(self):
"""warning that arrives together with response data (for example 6282 on a case #2 read) is final, too"""
link, data, sw = self._exchange('00b0000004', [('01020304', '6282')], strict=False)
self.assertEqual(link.sent, ['00b0000004'])
self.assertEqual((data, sw), ('01020304', '6282'))
def test_repeated_warning_does_not_loop(self):
"""warning -> dummy GET RESPONSE -> warning again must terminate"""
link, data, sw = self._exchange(CASE4, [('', '6281'), ('', '6281')])
self.assertEqual(link.sent, [CASE4_TPDU, '00c0000000'])
self.assertEqual((data, sw), ('', '6281'))
#### fixed GlobalPlatform GET STATUS pagination
def test_gp_6310_reaches_the_caller(self):
"""GET STATUS answers 6310"""
link, data, sw = self._exchange(GET_STATUS, [('', '6104'), ('e3024f00', '6310')])
self.assertEqual(link.sent, [GET_STATUS_TPDU, '84c0000004'])
self.assertEqual((data, sw), ('e3024f00', '6310'))
def test_gp_get_status_two_pages(self):
"""Both GET STATUS pages, page 1 6310, reissued with P2 bit 1 set, page 2 9000."""
page1 = self._exchange(GET_STATUS, [('', '6104'), ('e3024f00', '6310')])
self.assertEqual(page1[1:], ('e3024f00', '6310'))
page2 = self._exchange('84f22003094f005c054f9f70c5cc00',
[('', '6104'), ('e3024f01', '9000')])
self.assertEqual(page2[0].sent, ['84f22003094f005c054f9f70c5cc', '84c0000004'])
self.assertEqual(page2[1:], ('e3024f01', '9000'))
#### 6cxx and apdu_strict
def test_6cxx_reissues_command_with_correct_length(self):
link, data, sw = self._exchange('00b0000000', [('', '6c04'), ('01020304', '9000')])
self.assertEqual(link.sent, ['00b0000000', '00b0000004'])
self.assertEqual((data, sw), ('01020304', '9000'))
def test_strict_mode_does_not_auto_fetch_for_case3(self):
apdu = '00200001081122334455667788'
link, data, sw = self._exchange(apdu, [('', '6104')], strict=True)
self.assertEqual(link.sent, [apdu])
self.assertEqual((data, sw), ('', '6104'))
def test_non_strict_mode_auto_fetches_for_case3(self):
apdu = '00200001081122334455667788'
link, data, sw = self._exchange(apdu, [('', '6104'), ('aabbccdd', '9000')], strict=False)
self.assertEqual(link.sent, [apdu, '00c0000004'])
self.assertEqual((data, sw), ('aabbccdd', '9000'))
#### T=1 briefly
def test_t1_is_passed_through(self):
"""T=1 has no GET RESPONSE"""
link, data, sw = self._exchange(GET_STATUS, [('e3024f00', '6310')], protocol=1)
self.assertEqual(link.sent, [GET_STATUS.lower()])
self.assertEqual((data, sw), ('e3024f00', '6310'))
if __name__ == "__main__":
unittest.main()