# coding=utf-8 """Representation of the runtime state of an application like pySim-shell. """ # (C) 2021 by Harald Welte # # 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 . from typing import Optional, Tuple from osmocom.utils import h2b, i2h, is_hex, Hexstr from osmocom.tlv import bertlv_parse_one from pySim.exceptions import * from pySim.filesystem import * from pySim.log import PySimLogger log = PySimLogger.get("RUNTIME") def lchan_nr_from_cla(cla: int) -> int: """Resolve the logical channel number from the CLA byte.""" # TS 102 221 10.1.1 Coding of Class Byte if cla >> 4 in [0x0, 0xA, 0x8]: # Table 10.3 return cla & 0x03 if cla & 0xD0 in [0x40, 0xC0]: # Table 10.4a return 4 + (cla & 0x0F) raise ValueError('Could not determine logical channel for CLA=%2X' % cla) class RuntimeState: """Represent the runtime state of a session with a card.""" def __init__(self, card: 'CardBase', profile: 'CardProfile'): """ Args: card : pysim.cards.Card instance profile : CardProfile instance """ self.mf = CardMF(profile=profile) self.card = card self.profile = profile self.lchan = {} # the basic logical channel always exists self.lchan[0] = RuntimeLchan(0, self) # this is a dict of card identities which different parts of the code might populate, # typically with something like ICCID, EID, ATR, ... self.identity = {} self.adm_verified = False # make sure the class and selection control bytes, which are specified # by the card profile are used self.card.set_apdu_parameter( cla=self.profile.cla, sel_ctrl=self.profile.sel_ctrl) # make sure MF is selected before probing for Addons self.lchan[0].select('MF') for addon_cls in self.profile.addons: addon = addon_cls() if addon.probe(self.card): log.info("Detected %s Add-on \"%s\"" % (self.profile, addon)) for f in addon.files_in_mf: self.mf.add_file(f) # go back to MF before the next steps (addon probing might have changed DF) self.lchan[0].select('MF') # add application ADFs + MF-files from profile apps = self._match_applications() for a in apps: if a.adf: self.mf.add_application_df(a.adf) for f in self.profile.files_in_mf: self.mf.add_file(f) self.conserve_write = True # make sure that when the runtime state is created, the card is also # in a defined state. self.reset() def _match_applications(self): """match the applications from the profile with applications on the card""" apps_profile = self.profile.applications # When the profile does not feature any applications, then we are done already if not apps_profile: return [] # Read AIDs from card and match them against the applications defined by the # card profile aids_card = self.card.read_aids() apps_taken = [] if aids_card: aids_taken = [] log.info("AIDs on card:") for a in aids_card: for f in apps_profile: if f.aid in a: log.info(" %s: %s (EF.DIR)" % (f.name, a)) aids_taken.append(a) apps_taken.append(f) aids_unknown = set(aids_card) - set(aids_taken) for a in aids_unknown: log.info(" unknown: %s (EF.DIR)" % a) else: log.warn("EF.DIR seems to be empty!") # Some card applications may not be registered in EF.DIR, we will actively # probe for those applications for f in sorted(set(apps_profile) - set(apps_taken), key=str): try: # we can not use the lchan provided methods select, or select_file # since those method work on an already finished file model. At # this point we are still in the initialization process, so it is # no problem when we access the card object directly without caring # about updating other states. For normal selects at runtime, the # caller must use the lchan provided methods select or select_file! _data, sw = self.card.select_adf_by_aid(f.aid) self.selected_adf = f if sw == "9000": log.info(" %s: %s" % (f.name, f.aid)) apps_taken.append(f) except (SwMatchError, ProtocolError): pass return apps_taken def reset(self, cmd_app=None) -> Hexstr: """Perform physical card reset and obtain ATR. Args: cmd_app : Command Application State (for unregistering old file commands) """ # delete all lchan != 0 (basic lchan) for lchan_nr in list(self.lchan.keys()): self.lchan[lchan_nr].scc.scp = None if lchan_nr == 0: continue del self.lchan[lchan_nr] self.adm_verified = False atr = self.card.reset() if cmd_app: cmd_app.lchan = self.lchan[0] # select MF to reset internal state and to verify card really works self.lchan[0].select('MF', cmd_app) self.lchan[0].selected_adf = None # store ATR as part of our card identities dict self.identity['ATR'] = atr return atr def add_lchan(self, lchan_nr: int) -> 'RuntimeLchan': """Add a logical channel to the runtime state. You shouldn't call this directly but always go through RuntimeLchan.add_lchan().""" if lchan_nr in self.lchan.keys(): raise ValueError('Cannot create already-existing lchan %d' % lchan_nr) self.lchan[lchan_nr] = RuntimeLchan(lchan_nr, self) return self.lchan[lchan_nr] def del_lchan(self, lchan_nr: int): if lchan_nr in self.lchan.keys(): del self.lchan[lchan_nr] return True else: return False def get_lchan_by_cla(self, cla) -> Optional['RuntimeLchan']: lchan_nr = lchan_nr_from_cla(cla) if lchan_nr in self.lchan.keys(): return self.lchan[lchan_nr] else: return None class RuntimeLchan: """Represent the runtime state of a logical channel with a card.""" def __init__(self, lchan_nr: int, rs: RuntimeState): self.lchan_nr = lchan_nr self.rs = rs self.scc = self.rs.card._scc.fork_lchan(lchan_nr) # File reference data self.selected_file = self.rs.mf self.selected_adf = None self.selected_file_fcp = None self.selected_file_fcp_hex = None def add_lchan(self, lchan_nr: int) -> 'RuntimeLchan': """Add a new logical channel from the current logical channel. Just affects internal state, doesn't actually open a channel with the UICC.""" new_lchan = self.rs.add_lchan(lchan_nr) # See TS 102 221 Table 8.3 if self.lchan_nr != 0: new_lchan.selected_file = self.get_cwd() new_lchan.selected_adf = self.selected_adf return new_lchan def selected_file_descriptor_byte(self) -> dict: return self.selected_file_fcp['file_descriptor']['file_descriptor_byte'] def selected_file_shareable(self) -> bool: return self.selected_file_descriptor_byte()['shareable'] def selected_file_structure(self) -> str: return self.selected_file_descriptor_byte()['structure'] def selected_file_type(self) -> str: return self.selected_file_descriptor_byte()['file_type'] def selected_file_num_of_rec(self) -> Optional[int]: return self.selected_file_fcp['file_descriptor'].get('num_of_rec') def selected_file_record_len(self) -> Optional[int]: return self.selected_file_fcp['file_descriptor'].get('record_len') def selected_file_size(self) -> Optional[int]: return self.selected_file_fcp.get('file_size') def selected_file_reserved_file_size(self) -> Optional[int]: return self.selected_file_fcp['proprietary_information'].get('reserved_file_size') def selected_file_maximum_file_size(self) -> Optional[int]: return self.selected_file_fcp['proprietary_information'].get('maximum_file_size') def get_cwd(self) -> CardDF: """Obtain the current working directory. Returns: CardDF instance """ if isinstance(self.selected_file, CardDF): return self.selected_file else: return self.selected_file.parent def get_application_df(self) -> Optional[CardADF]: """Obtain the currently selected application DF (if any). Returns: CardADF() instance or None""" # iterate upwards from selected file; check if any is an ADF node = self.selected_file while node.parent != node: if isinstance(node, CardADF): return node node = node.parent return None def get_file_by_name(self, name: str) -> CardFile: """Obtain the file object from the file system tree by its name without actually selecting the file. Returns: CardFile() instance or None""" # handling of entire paths with multiple directories/elements if '/' in name: pathlist = name.split('/') # treat /DF.GSM/foo like MF/DF.GSM/foo if pathlist[0] == '': pathlist[0] = 'MF' else: pathlist = [name] # start in the current working directory (we can still # select any ADF and the MF from here, so those will be # among the selectables). file = self.get_cwd() for p in pathlist: # Look for the next file in the path list selectables = file.get_selectables() file = None for selectable in selectables: if selectable == p: file = selectables[selectable] break # When we hit none, then the given path must be invalid if file is None: return None # Return the file object found at the tip of the path return file def interpret_sw(self, sw: str): """Interpret a given status word relative to the currently selected application or the underlying card profile. Args: sw : Status word as string of 4 hex digits Returns: Tuple of two strings """ res = None adf = self.get_application_df() if adf: app = adf.application # The application either comes with its own interpret_sw # method or we will use the interpret_sw method from the # card profile. if app and hasattr(app, "interpret_sw"): res = app.interpret_sw(sw) return res or self.rs.profile.interpret_sw(sw) def probe_file(self, fid: str, cmd_app=None): """Blindly try to select a file and automatically add a matching file object if the file actually exists.""" if not is_hex(fid, 4, 4): raise ValueError( "Cannot select unknown file by name %s, only hexadecimal 4 digit FID is allowed" % fid) # unregister commands of old file self.unregister_cmds(cmd_app) try: # We access the card through the select_file method of the scc object. # If we succeed, we know that the file exists on the card and we may # proceed with creating a new CardEF object in the local file model at # run time. In case the file does not exist on the card, we just abort. # The state on the card (selected file/application) won't be changed, # so we do not have to update any state in that case. (data, _sw) = self.scc.select_file(fid) except SwMatchError as swm: self._select_post(cmd_app) k = self.interpret_sw(swm.sw_actual) if not k: raise swm raise RuntimeError("%s: %s - %s" % (swm.sw_actual, k[0], k[1])) from swm select_resp = self.selected_file.decode_select_response(data) if select_resp['file_descriptor']['file_descriptor_byte']['file_type'] == 'df': f = CardDF(fid=fid, sfid=None, name="DF." + str(fid).upper(), desc="dedicated file, manually added at runtime") else: if select_resp['file_descriptor']['file_descriptor_byte']['structure'] == 'transparent': f = TransparentEF(fid=fid, sfid=None, name="EF." + str(fid).upper(), desc="elementary file, manually added at runtime") else: f = LinFixedEF(fid=fid, sfid=None, name="EF." + str(fid).upper(), desc="elementary file, manually added at runtime") self.selected_file.add_files([f]) self._select_post(cmd_app, f, data) def _select_post(self, cmd_app, file:Optional[CardFile] = None, select_resp_data = None): # we store some reference data (see above) about the currently selected file. # This data must be updated after every select. if file: self.selected_file = file if isinstance(file, CardADF): self.selected_adf = file if select_resp_data: self.selected_file_fcp_hex = select_resp_data self.selected_file_fcp = self.selected_file.decode_select_response(select_resp_data) else: self.selected_file_fcp_hex = None self.selected_file_fcp = None # register commands of new file self.register_cmds(cmd_app) def select_file(self, file: CardFile, cmd_app=None): """Select a file (EF, DF, ADF, MF, ...). Args: file : CardFile [or derived class] instance cmd_app : Command Application State (for unregistering old file commands) """ if not isinstance(file, CardADF) and self.selected_adf and self.selected_adf.has_fs == False: # Not every application that may be present on a GlobalPlatform card will support the SELECT # command as we know it from ETSI TS 102 221, section 11.1.1. In fact the only subset of # SELECT we may rely on is the OPEN SELECT command as specified in GlobalPlatform Card # Specification, section 11.9. Unfortunately the OPEN SELECT command only supports the # "select by name" method, which means we can only select an application and not a file. # The consequence of this is that we may get trapped in an application that does not have # ISIM/USIM like file system support and the only way to leave that application is to select # an ISIM/USIM application in order to get the file system access back. # # To automate this escape-route we will first select an arbitrary ADF that has file system support first # and then continue normally. for selectable in self.rs.mf.get_selectables().items(): if isinstance(selectable[1], CardADF) and selectable[1].has_fs == True: self.select(selectable[1].name, cmd_app) break # we need to find a path from our self.selected_file to the destination inter_path = self.selected_file.build_select_path_to(file) if not inter_path: raise RuntimeError('Cannot determine path from %s to %s' % (self.selected_file, file)) # unregister commands of old file self.unregister_cmds(cmd_app) # be sure the variables that we pass to _select_post contain valid values. selected_file = self.selected_file data = self.selected_file_fcp_hex for f in inter_path: try: # We now directly accessing the card to perform the selection. This # will change the state of the card, so we must take care to update # the local state (lchan) as well. This is done in the method # _select_post. It should be noted that the caller must always use # the methods select_file or select. The caller must not access the # card directly since this would lead into an incoherence of the # card state and the state of the lchan. if isinstance(f, CardADF): (data, _sw) = self.rs.card.select_adf_by_aid(f.aid, scc=self.scc) else: (data, _sw) = self.scc.select_file(f.fid) selected_file = f except SwMatchError as swm: self._select_post(cmd_app, selected_file, data) raise swm self._select_post(cmd_app, f, data) def select(self, name: str, cmd_app=None): """Select a file (EF, DF, ADF, MF, ...). Args: name : Name of file to select cmd_app : Command Application State (for unregistering old file commands) """ # if any intermediate step fails, we must be able to go back where we were prev_sel_file = self.selected_file # handling of entire paths with multiple directories/elements if '/' in name: pathlist = name.split('/') # treat /DF.GSM/foo like MF/DF.GSM/foo if pathlist[0] == '': pathlist[0] = 'MF' try: for p in pathlist: self.select(p, cmd_app) return self.selected_file_fcp except Exception as e: self.select_file(prev_sel_file, cmd_app) raise e # we are now in the directory where the target file is located # so we can now refer to the get_selectables() method to get the # file object and select it using select_file() sels = self.selected_file.get_selectables() if is_hex(name): name = name.lower() try: if name in sels: self.select_file(sels[name], cmd_app) else: self.probe_file(name, cmd_app) except Exception as e: self.select_file(prev_sel_file, cmd_app) raise e return self.selected_file_fcp def status(self): """Request STATUS (current selected file FCP) from card.""" (data, _sw) = self.scc.status() return self.selected_file.decode_select_response(data) def get_file_for_filename(self, name: str): """Get the related CardFile object for a specified filename.""" sels = self.selected_file.get_selectables() return sels[name] def activate_file(self, name: str): """Request ACTIVATE FILE of specified file.""" sels = self.selected_file.get_selectables() f = sels[name] data, sw = self.scc.activate_file(f.fid) return data, sw def read_binary(self, length: int = None, offset: int = 0): """Read [part of] a transparent EF binary data. Args: length : Amount of data to read (None: as much as possible) offset : Offset into the file from which to read 'length' bytes Returns: binary data read from the file """ if not isinstance(self.selected_file, TransparentEF): raise TypeError("Only works with TransparentEF, but %s is %s" % (self.selected_file, self.selected_file.__class__.__mro__)) return self.scc.read_binary(self.selected_file.fid, length, offset) def read_binary_dec(self) -> Tuple[dict, str]: """Read [part of] a transparent EF binary data and decode it. Args: length : Amount of data to read (None: as much as possible) offset : Offset into the file from which to read 'length' bytes Returns: abstract decode data read from the file """ (data, sw) = self.read_binary() dec_data = self.selected_file.decode_hex(data) return (dec_data, sw) def __get_writeable_size(self): """ Determine the writable size (file or record) using the cached FCP parameters of the currently selected file. Return None in case the writeable size cannot be determined (no FCP available, FCP lacks size information). """ fcp = self.selected_file_fcp if not fcp: return None structure = fcp.get('file_descriptor', {}).get('file_descriptor_byte', {}).get('structure') if not structure: return None if structure == 'transparent': return fcp.get('file_size') elif structure == 'linear_fixed': return fcp.get('file_descriptor', {}).get('record_len') else: return None def __check_writeable_size(self, data_len): """ Guard against unsuccessful writes caused by attempts to write data that exceeds the file limits. """ writeable_size = self.__get_writeable_size() if not writeable_size: return if isinstance(self.selected_file, TransparentEF): writeable_name = "file" elif isinstance(self.selected_file, LinFixedEF): writeable_name = "record" else: writeable_name = "object" if data_len > writeable_size: raise TypeError("Data length (%u) exceeds %s size (%u) by %u bytes" % (data_len, writeable_name, writeable_size, data_len - writeable_size)) elif data_len < writeable_size: log.warn("Data length (%u) less than %s size (%u), leaving %u unwritten bytes at the end of the %s" % (data_len, writeable_name, writeable_size, writeable_size - data_len, writeable_name)) def update_binary(self, data_hex: str, offset: int = 0): """Update transparent EF binary data. Args: data_hex : hex string of data to be written offset : Offset into the file from which to write 'data_hex' """ if not isinstance(self.selected_file, TransparentEF): raise TypeError("Only works with TransparentEF, but %s is %s" % (self.selected_file, self.selected_file.__class__.__mro__)) self.__check_writeable_size(len(data_hex) // 2 + offset) return self.scc.update_binary(self.selected_file.fid, data_hex, offset, conserve=self.rs.conserve_write) def update_binary_dec(self, data: dict): """Update transparent EF from abstract data. Encodes the data to binary and then updates the EF with it. Args: data : abstract data which is to be encoded and written """ data_hex = self.selected_file.encode_hex(data, self.selected_file_size()) return self.update_binary(data_hex) def read_record(self, rec_nr: int = 0): """Read a record as binary data. Args: rec_nr : Record number to read Returns: hex string of binary data contained in record """ if not isinstance(self.selected_file, LinFixedEF): raise TypeError("Only works with Linear Fixed EF, but %s is %s" % (self.selected_file, self.selected_file.__class__.__mro__)) # returns a string of hex nibbles return self.scc.read_record(self.selected_file.fid, rec_nr) def read_record_dec(self, rec_nr: int = 0) -> Tuple[dict, str]: """Read a record and decode it to abstract data. Args: rec_nr : Record number to read Returns: abstract data contained in record """ (data, sw) = self.read_record(rec_nr) return (self.selected_file.decode_record_hex(data, rec_nr), sw) def update_record(self, rec_nr: int, data_hex: str): """Update a record with given binary data Args: rec_nr : Record number to read data_hex : Hex string binary data to be written """ if not isinstance(self.selected_file, LinFixedEF): raise TypeError("Only works with Linear Fixed EF, but %s is %s" % (self.selected_file, self.selected_file.__class__.__mro__)) self.__check_writeable_size(len(data_hex) // 2) return self.scc.update_record(self.selected_file.fid, rec_nr, data_hex, conserve=self.rs.conserve_write, leftpad=self.selected_file.leftpad) def update_record_dec(self, rec_nr: int, data: dict): """Update a record with given abstract data. Will encode abstract to binary data and then write it to the given record on the card. Args: rec_nr : Record number to read data_hex : Abstract data to be written """ data_hex = self.selected_file.encode_record_hex(data, rec_nr, self.selected_file_record_len()) return self.update_record(rec_nr, data_hex) def retrieve_data(self, tag: int = 0): """Read a DO/TLV as binary data. Args: tag : Tag of TLV/DO to read Returns: hex string of full BER-TLV DO including Tag and Length """ if not isinstance(self.selected_file, BerTlvEF): raise TypeError("Only works with BER-TLV EF") # returns a string of hex nibbles return self.scc.retrieve_data(self.selected_file.fid, tag) def retrieve_tags(self): """Retrieve tags available on BER-TLV EF. Returns: list of integer tags contained in EF """ if not isinstance(self.selected_file, BerTlvEF): raise TypeError("Only works with BER-TLV EF, but %s is %s" % (self.selected_file, self.selected_file.__class__.__mro__)) data, _sw = self.scc.retrieve_data(self.selected_file.fid, 0x5c) _tag, _length, value, _remainder = bertlv_parse_one(h2b(data)) return list(value) def set_data(self, tag: int, data_hex: str): """Update a TLV/DO with given binary data Args: tag : Tag of TLV/DO to be written data_hex : Hex string binary data to be written (value portion) """ if not isinstance(self.selected_file, BerTlvEF): raise TypeError("Only works with BER-TLV EF, but %s is %s" % (self.selected_file, self.selected_file.__class__.__mro__)) return self.scc.set_data(self.selected_file.fid, tag, data_hex, conserve=self.rs.conserve_write) def register_cmds(self, cmd_app=None): """Register command set that is associated with the currently selected file""" if cmd_app and self.selected_file.shell_commands: for c in self.selected_file.shell_commands: cmd_app.register_command_set(c) def unregister_cmds(self, cmd_app=None): """Unregister command set that is associated with the currently selected file""" if cmd_app and self.selected_file.shell_commands: for c in self.selected_file.shell_commands: cmd_app.unregister_command_set(c)