diff --git a/pySim/global_platform/__init__.py b/pySim/global_platform/__init__.py index ed358106..9600c7cb 100644 --- a/pySim/global_platform/__init__.py +++ b/pySim/global_platform/__init__.py @@ -37,6 +37,9 @@ from pySim.filesystem import * from pySim.profile import CardProfile from pySim.ota import SimFileAccessAndToolkitAppSpecParams from pySim.javacard import CapFile +from pySim.log import PySimLogger + +log = PySimLogger.get(__name__) # GPCS Table 11-48 Load Parameter Tags class NonVolatileCodeMinMemoryReq(BER_TLV_IE, tag=0xC6): @@ -532,6 +535,63 @@ class GpRegistryRelatedData(BER_TLV_IE, tag=0xe3, nested=[ApplicationAID, LifeCy ExecutableModuleAID, AssociatedSecurityDomainAID]): pass +# GP CS v2.3.1 Table 11-36/11-37 possible data objects requested/returned from GET STATUS for each registry entry. +# Applications and Executable Load Files have _different_ sets, so a tag list requesting them has +# to match the subset because 11.4.2.3 warns that asking for a data object an entry does not have +# "may" be answered with an error status. +GetStatusTagListIEs = { + # Table 11-36 GP Application Data + 'isd': [ApplicationAID, LifeCycleState, Privileges, ImplicitSelectionParameter, + ExecutableLoadFileAID, AssociatedSecurityDomainAID], + 'applications': [ApplicationAID, LifeCycleState, Privileges, ImplicitSelectionParameter, + ExecutableLoadFileAID, AssociatedSecurityDomainAID], + # Table 11-37 GP Executable Load File Data. 84 only for the subset that asks for the modules (Note 2)! + 'files': [ApplicationAID, LifeCycleState, ExecutableLoadFileVersionNumber, + AssociatedSecurityDomainAID], + 'files_and_modules': [ApplicationAID, LifeCycleState, ExecutableLoadFileVersionNumber, + ExecutableModuleAID, AssociatedSecurityDomainAID], +} + +def get_status_tag_list(subset: str) -> bytes: + """Encode the GET STATUS tag list for the given status subset""" + tags = b''.join([bertlv_encode_tag(ie.tag) for ie in GetStatusTagListIEs[subset]]) + return b'\x5c' + bertlv_encode_len(len(tags)) + tags + +# GP CS v2.3.1 Appendix H.2 / Table H-1 +# oid prefix {iso(1) member-body(2) country-USA(840) globalPlatform(114283)} + card management type 2 +# afterwards GP version. +OID_GP_CARD_MGMT_TYPE = h2b('2a864886fc6b02') + +def _find_tlv_value(decoded, key: str): + """depth first search for the nested decoded TLV_IE dict/list""" + if isinstance(decoded, dict): + for k, v in decoded.items(): + if k == key: + return v + found = _find_tlv_value(v, key) + if found is not None: + return found + elif isinstance(decoded, list): + for item in decoded: + found = _find_tlv_value(item, key) + if found is not None: + return found + return None + +def decode_gp_version(card_data: bytes) -> Optional[Tuple[int, ...]]: + """GP version from Card Data returned by GET DATA, like (2, 1, 1) or (2, 2). + None if cm type OID is absent/unknown""" + cd = CardData() + cd.from_tlv(card_data) + ctv = _find_tlv_value(cd.to_dict(), 'card_management_type_and_version') + oid = _find_tlv_value(ctv, 'object_identifier') if ctv is not None else None + if oid is None: + return None + oid = h2b(oid) if isinstance(oid, str) else bytes(oid) + if not oid.startswith(OID_GP_CARD_MGMT_TYPE): + return None + return tuple(oid[len(OID_GP_CARD_MGMT_TYPE):]) + # Application Dedicated File of a Security Domain class ADF_SD(CardADF): StoreData = BitStruct('last_block'/Flag, @@ -733,19 +793,64 @@ class ADF_SD(CardADF): for grd in grd_list: self._cmd.poutput_json(grd.to_dict()) + def gp_version(self) -> Optional[Tuple[int, ...]]: + """GP version the selected SD reports in its Card Recognition + Data, e.g. (2, 1, 1). Card Recognition Data "shall be present" v2.1.1/v2.3.1 section 7.4.1.3, + so this must succeed no matter the GP version. None if card did not answer GET DATA / OID unknown. + Cached, it cannot change during a session.""" + if not hasattr(self, '_gp_version'): + self._gp_version = None + try: + data, _sw = self._cmd.lchan.scc.get_data(cla=0x80, tag=CardData.tag) + self._gp_version = decode_gp_version(h2b(data)) + except (SwMatchError, ValueError) as e: + log.warning("Could not determine GlobalPlatform version: %s", e) + return self._gp_version + def get_status(self, subset:str, aid_search_qualifier:Hexstr = '') -> List[GpRegistryRelatedData]: - subset_hex = b2h(build_construct(StatusSubset, subset)) aid = ApplicationAID(decoded=aid_search_qualifier) - cmd_data = aid.to_tlv() + h2b('5c054f9f70c5cc') + # GPC CardSpec v2.3.1 Table 11-35 says only the AID search tag is mandatory, tag list is + # Optional and not present in the older v2.1.1, where section 9.4.2.3 defines the data + # field as the search qualifier. + # Cards like the sja5 implementing that old GP version reject anything else with 6A80 + # from v2.1.1 Table 9-26 so only send a tag list to a card that announces v2.2 or later. + # + # Not sending one is not a problem on older cards, the tag list only gives us data beyond + # what 11.4.3.1 gives us anyway, for example the associated SD AID which matters on an eUICC + # where entries belong to different SD. + version = self.gp_version() + log.debug("Card Recognition Data reports GlobalPlatform %s", + '.'.join(str(v) for v in version) if version else 'unknown') + if version is not None and version >= (2, 2): + try: + return self._get_status(subset, aid.to_tlv() + get_status_tag_list(subset)) + except SwMatchError as e: + # Retry if v2.2 or later but rejected the tag list anyway. + # 6A80 and 6A88 are the error conditions GET STATUS defines in table 11-39. + # Retrying beats not ending up with a list again... + if e.sw_actual not in ('6a80', '6a88'): + raise + log.warning("Card reports GlobalPlatform %s but answered %s to the GET STATUS tag list; " + "retrying with the default search", + '.'.join(str(v) for v in version), e.sw_actual) + return self._get_status(subset, aid.to_tlv(), empty_on_6a88=True) + + def _get_status(self, subset:str, cmd_data:bytes, + empty_on_6a88: bool = False) -> List[GpRegistryRelatedData]: + subset_hex = b2h(build_construct(StatusSubset, subset)) 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 + # Table 11-39 "Referenced data not found". After collecting all pages this can + # only mean "nothing more matches" -> listing is complete. On the first page + # it is ambiguous, empty result or bad command data field, so leave that to get_status() + # which knows if a tag list was sent. + if grd_list or empty_on_6a88: + return grd_list + raise SwMatchError(sw, ['9000', '6310']) if sw not in ['9000', '6310']: # Never return a silently truncated registry raise SwMatchError(sw, ['9000', '6310']) diff --git a/tests/unittests/test_globalplatform.py b/tests/unittests/test_globalplatform.py index c48f2607..d329c457 100644 --- a/tests/unittests/test_globalplatform.py +++ b/tests/unittests/test_globalplatform.py @@ -712,14 +712,26 @@ class Load_ChunkLen_Test(unittest.TestCase): for wrapped in scc.wrapped: self.assertLessEqual(wrapped[4], 255) +# Real Card Data (GET DATA '66'), as returned by sja5 + euicc +CARD_DATA_V211 = ('6631732f06072a864886fc6b01600c060a2a864886fc6b0202010163090607' + '2a864886fc6b03640b06092a864886fc6b040215') +CARD_DATA_V22 = ('663b733906072a864886fc6b01600b06092a864886fc6b020202630906072a86' + '4886fc6b03640b06092a864886fc6b040370640b06092a864886fc6b04810400') + class _FakeScc: """mock lchan.scc: replays scripted (data, sw) pairs + records the APDUs sent.""" - def __init__(self, responses): + def __init__(self, responses, card_data=CARD_DATA_V211): self._responses = list(responses) + self._card_data = card_data self.sent = [] + def get_data(self, cla, tag): + if self._card_data is None: + raise SwMatchError('6a88', '9000') + return self._card_data, '9000' + def send_apdu(self, apdu): self.sent.append(apdu.lower()) if not self._responses: @@ -727,6 +739,28 @@ class _FakeScc: return self._responses.pop(0) +class GpVersion_Test(unittest.TestCase): + """GP version from Card Recognition Data, which v2.1.1/v2.3.1 section 7.4.1.3 + require to be present. The OID under tag 60 is {globalPlatform 2 v...}.""" + + def test_decode_real_cards(self): + self.assertEqual(decode_gp_version(h2b(CARD_DATA_V211)), (2, 1, 1)) + self.assertEqual(decode_gp_version(h2b(CARD_DATA_V22)), (2, 2)) + + def test_unknown_oid_is_none(self): + self.assertIsNone(decode_gp_version(h2b('66097307060512345678'))) + + def test_tag_lists_follow_the_spec_tables(self): + """table 11-36 applications, table 11-37 for load files""" + self.assertEqual(b2h(get_status_tag_list('isd')), '5c074f9f70c5cfc4cc') + self.assertEqual(b2h(get_status_tag_list('applications')), '5c074f9f70c5cfc4cc') + self.assertEqual(b2h(get_status_tag_list('files')), '5c054f9f70cecc') + self.assertEqual(b2h(get_status_tag_list('files_and_modules')), '5c064f9f70ce84cc') + # C5 never load files, 84 never applications + self.assertNotIn('c5', b2h(get_status_tag_list('files'))) + self.assertNotIn('84', b2h(get_status_tag_list('applications'))[4:]) + + class GetStatus_Pagination_Test(unittest.TestCase): """GPC v2.3.1 section 11.4.3.2 table 11-38 GET STATUS pagination test @@ -737,8 +771,8 @@ class GetStatus_Pagination_Test(unittest.TestCase): ENTRY_1 = 'e3074f05a000000151' ENTRY_2 = 'e3074f05a000000152' - def _sd(self, responses): - scc = _FakeScc(responses) + def _sd(self, responses, card_data=CARD_DATA_V211): + scc = _FakeScc(responses, card_data) 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}) @@ -750,15 +784,15 @@ class GetStatus_Pagination_Test(unittest.TestCase): 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(scc.sent, ['80f24002024f0000']) 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(scc.sent, ['80f24002024f0000', + '80f24003024f0000']) self.assertEqual(self._aids(grd_list), ['a000000151', 'a000000152']) def test_three_pages_keep_p2_next_occurrence(self): @@ -772,6 +806,44 @@ class GetStatus_Pagination_Test(unittest.TestCase): sd, _scc = self._sd([('', '6a88')]) self.assertEqual(sd.get_status('applications'), []) + def test_v211_card_gets_no_tag_list(self): + """v2.1.1 section 9.4.2.3 has no tag list,not send a tag list""" + sd, scc = self._sd([(self.ENTRY_1, '9000')], card_data=CARD_DATA_V211) + sd.get_status('applications') + self.assertEqual(scc.sent, ['80f24002024f0000']) + self.assertNotIn('5c', scc.sent[0][8:]) + + def test_v22_card_gets_a_tag_list(self): + sd, scc = self._sd([(self.ENTRY_1, '9000')], card_data=CARD_DATA_V22) + sd.get_status('applications') + self.assertEqual(scc.sent, ['80f240020b4f005c074f9f70c5cfc4cc00']) + + def test_unknown_version_gets_no_tag_list(self): + """If the card will not say, assume the conservative form that works everywhere.""" + sd, scc = self._sd([(self.ENTRY_1, '9000')], card_data=None) + sd.get_status('applications') + self.assertEqual(scc.sent, ['80f24002024f0000']) + + def test_v22_card_rejecting_tag_list_falls_back(self): + """card announcing v2.2+ that still answers 6A80 to the tag list.""" + sd, scc = self._sd([('', '6a80'), (self.ENTRY_1, '9000')], card_data=CARD_DATA_V22) + grd_list = sd.get_status('applications') + self.assertEqual(scc.sent, ['80f240020b4f005c074f9f70c5cfc4cc00', + '80f24002024f0000']) + self.assertEqual(self._aids(grd_list), ['a000000151']) + + def test_aid_search_qualifier(self): + sd, scc = self._sd([(self.ENTRY_1, '9000')]) + sd.get_status('applications', 'a000000087') + self.assertEqual(scc.sent, ['80f24002074f05a00000008700']) + + def test_6a80_is_reported_on_a_v211_card(self): + """no tag list -> 6A80 is error""" + sd, _scc = self._sd([('', '6a80')], card_data=CARD_DATA_V211) + with self.assertRaises(SwMatchError) as ctx: + sd.get_status('applications') + self.assertEqual(ctx.exception.sw_actual, '6a80') + def test_unexpected_sw_is_not_silently_truncated(self): """partial is not complete result""" sd, _scc = self._sd([(self.ENTRY_1, '6310'), ('', '6982')]) @@ -779,7 +851,26 @@ class GetStatus_Pagination_Test(unittest.TestCase): sd.get_status('applications') self.assertEqual(ctx.exception.sw_actual, '6982') + def test_v22_card_answering_6a88_to_the_tag_list_falls_back(self): + """6A88 is the other GET STATUS error condition of table 11-39, section 11.4.2.3 + says we may get get an error status. 6A88 to the tag-list attempt should be retried + without it or we get nothing""" + sd, scc = self._sd([('', '6a88'), (self.ENTRY_1, '9000')], card_data=CARD_DATA_V22) + grd_list = sd.get_status('applications') + self.assertEqual(scc.sent, ['80f240020b4f005c074f9f70c5cfc4cc00', + '80f24002024f0000']) + self.assertEqual(self._aids(grd_list), ['a000000151']) + def test_v22_card_with_a_genuinely_empty_subset(self): + """...and when the retry answers 6A88, the list really is empty.""" + sd, scc = self._sd([('', '6a88'), ('', '6a88')], card_data=CARD_DATA_V22) + self.assertEqual(sd.get_status('applications'), []) + self.assertEqual(len(scc.sent), 2) + + def test_6a88_after_a_page_keeps_that_page(self): + """6A88 is "no more matches" after we have data, we're done""" + sd, _scc = self._sd([(self.ENTRY_1, '6310'), ('', '6a88')], card_data=CARD_DATA_V22) + self.assertEqual(self._aids(sd.get_status('applications')), ['a000000151']) if __name__ == "__main__": unittest.main()