Hoernchen has uploaded this change for review. ( https://gerrit.osmocom.org/c/pysim/+/43551?usp=email )
Change subject: transport: stop the T=0 layer from breaking GP 6310 ......................................................................
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 GP CS v2.3 section 11.4.3.1 the GP GET STATUS (80 F2) answers 6310 to say "additional matches, reissue the command with P2 bit 1 set", 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 --- M pySim/global_platform/__init__.py M pySim/transport/__init__.py M tests/unittests/test_globalplatform.py M tests/unittests/test_transport.py 4 files changed, 299 insertions(+), 6 deletions(-)
git pull ssh://gerrit.osmocom.org:29418/pysim refs/changes/51/43551/1
diff --git a/pySim/global_platform/__init__.py b/pySim/global_platform/__init__.py index a450561..80772fe 100644 --- a/pySim/global_platform/__init__.py +++ b/pySim/global_platform/__init__.py @@ -742,17 +742,23 @@ 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 + # GPC CardSpec v2.3, section 11.4.3.1, 6310 == additional matches. + p2 |= 0x01
set_status_parser = argparse.ArgumentParser() set_status_parser.add_argument('scope', choices=list(SetStatusScope.ksymapping.values()), diff --git a/pySim/transport/__init__.py b/pySim/transport/__init__.py index 4fb38f1..13e0c42 100644 --- a/pySim/transport/__init__.py +++ b/pySim/transport/__init__.py @@ -331,6 +331,18 @@ # 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. + # GP CS 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) @@ -343,7 +355,7 @@ # 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) @@ -358,6 +370,7 @@ 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] diff --git a/tests/unittests/test_globalplatform.py b/tests/unittests/test_globalplatform.py index 78a195e..2dbec70 100644 --- a/tests/unittests/test_globalplatform.py +++ b/tests/unittests/test_globalplatform.py @@ -713,5 +713,73 @@ 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): + """GP CS v2.3 section 11.4.3.1 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() diff --git a/tests/unittests/test_transport.py b/tests/unittests/test_transport.py index b2c7f08..268ccb2 100644 --- a/tests/unittests/test_transport.py +++ b/tests/unittests/test_transport.py @@ -1,9 +1,28 @@ #!/usr/bin/env python3
+"""Transport (as in t0/t1) tests""" + +# (C) 2026 by sysmocom - s.f.m.c. GmbH info@sysmocom.de +# +# Author: Eric Wild +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see http://www.gnu.org/licenses/. + 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(): @@ -51,5 +70,192 @@ 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()