diff --git a/pySim/exceptions.py b/pySim/exceptions.py index 1c9e3b89..d8e4964f 100644 --- a/pySim/exceptions.py +++ b/pySim/exceptions.py @@ -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 diff --git a/pySim/global_platform/__init__.py b/pySim/global_platform/__init__.py index a450561d..ed358106 100644 --- a/pySim/global_platform/__init__.py +++ b/pySim/global_platform/__init__.py @@ -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()), diff --git a/pySim/transport/__init__.py b/pySim/transport/__init__.py index 268eec60..5a630c81 100644 --- a/pySim/transport/__init__.py +++ b/pySim/transport/__init__.py @@ -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] diff --git a/tests/unittests/test_globalplatform.py b/tests/unittests/test_globalplatform.py index 78a195ec..c48f2607 100644 --- a/tests/unittests/test_globalplatform.py +++ b/tests/unittests/test_globalplatform.py @@ -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() diff --git a/tests/unittests/test_transport.py b/tests/unittests/test_transport.py index 7a9c69c6..f7e71d90 100644 --- a/tests/unittests/test_transport.py +++ b/tests/unittests/test_transport.py @@ -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()