Files
pysim-local/pySim/esim/saip/__init__.py
Harald Welte b6532b56d2 saip-tool: Add 'extract-apps' to dump all applications from eSIM profile
This new action can be used to dump all java applications as either raw
IJC file or converted to CAP format (the usual format generated by
JavaCard toolchains).

Change-Id: I51cffa5ba3ddbea491341d678ec9249d7cf470a5
2024-06-11 08:45:27 +02:00

636 lines
26 KiB
Python

# Implementation of SimAlliance/TCA Interoperable Profile handling
#
# (C) 2023-2024 by Harald Welte <laforge@osmocom.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 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 Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import abc
import io
from typing import Tuple, List, Optional, Dict, Union
from collections import OrderedDict
import asn1tools
from pySim.utils import bertlv_parse_tag, bertlv_parse_len, b2h, h2b, dec_imsi
from pySim.ts_102_221 import FileDescriptor
from pySim.construct import build_construct
from pySim.esim import compile_asn1_subdir
from pySim.esim.saip import templates
from pySim.esim.saip import oid
from pySim.tlv import BER_TLV_IE
from pySim.global_platform import KeyType, KeyUsageQualifier
from pySim.global_platform.uicc import UiccSdInstallParams
asn1 = compile_asn1_subdir('saip')
class Naa:
"""A class defining a Network Access Application (NAA)."""
name = None
# AID prefix, as used for ADF and EF.DIR
aid = None
# the ProfileElement types used specifically in this NAA
pe_types = []
# we only use the base DN of each OID; there may be subsequent versions underneath it
templates = []
mandatory_services = []
@classmethod
def adf_name(cls):
return 'adf-' + cls.mandatory_services[0]
class NaaCsim(Naa):
name = "csim"
aid = h2b("")
mandatory_services = ["csim"]
pe_types = ["csim", "opt-csim", "cdmaParameter"]
templates = [oid.ADF_CSIM_by_default, oid.ADF_CSIM_not_by_default]
class NaaUsim(Naa):
name = "usim"
aid = h2b("")
mandatory_services = ["usim"]
pe_types = ["usim", "opt-usim"]
templates = [oid.ADF_USIM_by_default, oid.ADF_USIM_not_by_default,
oid.DF_PHONEBOOK_ADF_USIM, oid.DF_GSM_ACCESS_ADF_USIM,
oid.DF_EAP, oid.DF_5GS, oid.DF_SAIP, oid.DF_SNPN,
oid.DF_5GProSe]
class NaaIsim(Naa):
name = "isim"
aid = h2b("")
mandatory_services = ["isim"]
pe_types = ["isim", "opt-isim"]
templates = [oid.ADF_ISIM_by_default, oid.ADF_ISIM_not_by_default]
NAAs = {
NaaCsim.name: NaaCsim,
NaaUsim.name: NaaUsim,
NaaIsim.name: NaaIsim,
}
class File:
"""Internal representation of a file in a profile filesystem.
Parameters:
pename: Name string of the profile element
l: List of tuples [fileDescriptor, fillFileContent, fillFileOffset profile elements]
template: Applicable FileTemplate describing defaults as per SAIP spec
"""
def __init__(self, pename: str, l: Optional[List[Tuple]] = None, template: Optional[templates.FileTemplate] = None):
self.pe_name = pename
self.template = template
self.fileDescriptor = {}
self.stream = None
# apply some defaults from profile
if self.template:
self.from_template(self.template)
if l:
self.from_tuples(l)
def from_template(self, template: templates.FileTemplate):
"""Determine defaults for file based on given FileTemplate."""
fdb_dec = {}
self.rec_len = None
if template.fid:
self.fileDescriptor['fileID'] = template.fid.to_bytes(2, 'big')
if template.sfi:
self.fileDescriptor['shortEFID'] = bytes([template.sfi])
if template.arr:
self.fileDescriptor['securityAttributesReferenced'] = bytes([template.arr])
# All the files defined in the templates shall have, by default, shareable/not-shareable bit in the file descriptor set to "shareable".
fdb_dec['shareable'] = True
if template.file_type in ['LF', 'CY']:
fdb_dec['file_type'] = 'working_ef'
if template.rec_len:
self.record_len = template.rec_len
if template.nb_rec and template.rec_len:
self.fileDescriptor['efFileSize'] = (template.nb_rec * template.rec_len).to_bytes(2, 'big') # FIXME
if template.file_type == 'LF':
fdb_dec['structure'] = 'linear_fixed'
elif template.file_type == 'CY':
fdb_dec['structure'] = 'cyclic'
elif template.file_type in ['TR', 'BT']:
fdb_dec['file_type'] = 'working_ef'
if template.file_size:
self.fileDescriptor['efFileSize'] = template.file_size.to_bytes(2, 'big') # FIXME
if template.file_type == 'BT':
fdb_dec['structure'] = 'ber_tlv'
elif template.file_type == 'TR':
fdb_dec['structure'] = 'transparent'
elif template.file_type in ['MF', 'DF', 'ADF']:
fdb_dec['file_type'] = 'df'
fdb_dec['structure'] = 'no_info_given'
# build file descriptor based on above input data
fd_dict = {'file_descriptor_byte': fdb_dec}
if self.rec_len:
fd_dict['record_len'] = self.rec_len
self.fileDescriptor['fileDescriptor'] = build_construct(FileDescriptor._construct, fd_dict)
# FIXME: default_val
# FIXME: high_update
# FIXME: params?
def from_tuples(self, l:List[Tuple]):
"""Parse a list of fileDescriptor, fillFileContent, fillFileOffset tuples into this instance."""
def get_fileDescriptor(l:List[Tuple]):
for k, v in l:
if k == 'fileDescriptor':
return v
fd = get_fileDescriptor(l)
if not fd:
raise ValueError("No fileDescriptor found")
self.fileDescriptor.update(dict(fd))
self.stream = self.linearize_file_content(l)
def to_tuples(self) -> List[Tuple]:
"""Generate a list of fileDescriptor, fillFileContent, fillFileOffset tuples into this instance."""
raise NotImplementedError
@staticmethod
def linearize_file_content(l: List[Tuple]) -> Optional[io.BytesIO]:
"""linearize a list of fillFileContent / fillFileOffset tuples into a stream of bytes."""
stream = io.BytesIO()
for k, v in l:
if k == 'doNotCreate':
return None
if k == 'fileDescriptor':
pass
elif k == 'fillFileOffset':
stream.write(b'\xff' * v)
elif k == 'fillFileContent':
stream.write(v)
else:
return ValueError("Unknown key '%s' in tuple list" % k)
return stream
def __str__(self) -> str:
return "File(%s)" % self.pe_name
def __repr__(self) -> str:
return "File(%s): %s" % (self.pe_name, self.fileDescriptor)
class ProfileElement:
"""Class representing a Profile Element (PE) within a SAIP Profile."""
FILE_BEARING = ['mf', 'cd', 'telecom', 'usim', 'opt-usim', 'isim', 'opt-isim', 'phonebook', 'gsm-access',
'csim', 'opt-csim', 'eap', 'df-5gs', 'df-saip', 'df-snpn', 'df-5gprose', 'iot', 'opt-iot']
# in their infinite wisdom the spec authors used inconsistent/irregular naming of PE type vs. hedaer field
# names, so we have to manually translate the exceptions here...
header_name_translation_dict = {
'header': None,
'genericFileManagement': 'gfm-header',
'akaParameter': 'aka-header',
'cdmaParameter': 'cdma-header',
# note how they couldn't even consistently captialize the 'header' suffix :(
'application': 'app-Header',
'pukCodes': 'puk-Header',
'pinCodes': 'pin-Header',
'securityDomain': 'sd-Header',
}
def __init__(self, decoded = None):
self.decoded = decoded
def _fixup_sqnInit_dec(self) -> None:
"""asn1tools has a bug when working with SEQUENCE OF that have DEFAULT values. Let's work around
this."""
if self.type != 'akaParameter':
return
sqn_init = self.decoded.get('sqnInit', None)
if not sqn_init:
return
# this weird '0x' value in a string is what we get from our (slightly hacked) ASN.1 syntax
if sqn_init == '0x000000000000':
# SEQUENCE (SIZE (32)) OF OCTET STRING (SIZE (6))
self.decoded['sqnInit'] = [b'\x00'*6] * 32
def _fixup_sqnInit_enc(self) -> None:
"""asn1tools has a bug when working with SEQUENCE OF that have DEFAULT values. Let's work around
this."""
if self.type != 'akaParameter':
return
sqn_init = self.decoded.get('sqnInit', None)
if not sqn_init:
return
for s in sqn_init:
if any(s):
return
# none of the fields were initialized with a non-default (non-zero) value, so we can skip it
del self.decoded['sqnInit']
@property
def header_name(self) -> str:
"""Return the name of the header field within the profile element."""
# unneccessarry compliaction by inconsistent naming :(
if self.type.startswith('opt-'):
return self.type.replace('-','') + '-header'
if self.type in self.header_name_translation_dict:
return self.header_name_translation_dict[self.type]
return self.type + '-header'
@property
def header(self):
"""Return the decoded ProfileHeader."""
return self.decoded.get(self.header_name, None)
@property
def identification(self):
if self.header:
return self.header['identification']
else:
return None
@property
def templateID(self):
"""Return the decoded templateID used by this profile element (if any)."""
return self.decoded.get('templateID', None)
@property
def files(self):
"""Return dict of decoded 'File' ASN.1 items."""
if not self.type in self.FILE_BEARING:
return {}
return {k:v for (k,v) in self.decoded.items() if k not in ['templateID', self.header_name]}
@classmethod
def from_der(cls, der: bytes) -> 'ProfileElement':
class4petype = {
'securityDomain': ProfileElementSD,
'usim': ProfileElementUSIM,
'isim': ProfileElementISIM,
}
"""Construct an instance from given raw, DER encoded bytes."""
pe_type, decoded = asn1.decode('ProfileElement', der)
if pe_type in class4petype:
inst = class4petype[pe_type](decoded)
else:
inst = ProfileElement(decoded)
inst.type = pe_type
# work around asn1tools bug regarding DEFAULT for a SEQUENCE OF
inst._fixup_sqnInit_dec()
# run any post-decoder a derived class may have
if hasattr(inst, '_post_decode'):
inst._post_decode()
return inst
def to_der(self) -> bytes:
"""Build an encoded DER representation of the instance."""
# run any pre-encoder a derived class may have
if hasattr(self, '_pre_encode'):
self._pre_encode()
# work around asn1tools bug regarding DEFAULT for a SEQUENCE OF
self._fixup_sqnInit_enc()
return asn1.encode('ProfileElement', (self.type, self.decoded))
def __str__(self) -> str:
return self.type
class SecurityDomainKeyComponent:
"""Representation of a key-component of a key for a security domain."""
def __init__(self, key_type: str, key_data: bytes, mac_length: int = 8):
self.key_type = key_type
self.key_data = key_data
self.mac_length = mac_length
def __repr__(self) -> str:
return 'SdKeyComp(type=%s, mac_len=%u, data=%s)' % (self.key_type, self.mac_length,
b2h(self.key_data))
@classmethod
def from_saip_dict(cls, saip: dict) -> 'SecurityDomainKeyComponent':
"""Construct instance from the dict as generated by SAIP asn.1 decoder."""
return cls(KeyType.parse(saip['keyType']), saip['keyData'], saip['macLength'])
def to_saip_dict(self) -> dict:
"""Express instance in the dict format required by SAIP asn.1 encoder."""
return {'keyType': KeyType.build(self.key_type),
'keyData': self.key_data,
'macLength': self.mac_length}
class SecurityDomainKey:
"""Represenation of a key used for SCP access to a security domain."""
def __init__(self, key_version_number: int, key_id: int, key_usage_qualifier: dict,
key_components: List[SecurityDomainKeyComponent]):
self.key_usage_qualifier = key_usage_qualifier
self.key_identifier = key_id
self.key_version_number = key_version_number
self.key_components = key_components
def __repr__(self) -> str:
return 'SdKey(KVN=0x%02x, ID=0x%02x, Usage=%s, Comp=%s)' % (self.key_version_number,
self.key_identifier,
self.key_usage_qualifier,
repr(self.key_components))
@classmethod
def from_saip_dict(cls, saip: dict) -> 'SecurityDomainKey':
"""Construct instance from the dict as generated by SAIP asn.1 decoder."""
inst = cls(int.from_bytes(saip['keyVersionNumber'], "big"),
int.from_bytes(saip['keyIdentifier'], "big"),
KeyUsageQualifier.parse(saip['keyUsageQualifier']),
[SecurityDomainKeyComponent.from_saip_dict(x) for x in saip['keyComponents']])
return inst
def to_saip_dict(self) -> dict:
"""Express instance in the dict format required by SAIP asn.1 encoder."""
return {'keyUsageQualifier': KeyUsageQualifier.build(self.key_usage_qualifier),
'keyIdentifier': bytes([self.key_identifier]),
'keyVersionNumber': bytes([self.key_version_number]),
'keyComponents': [k.to_saip_dict() for k in self.key_components]}
class ProfileElementSD(ProfileElement):
"""Class representing a securityDomain ProfileElement."""
type = 'securityDomain'
class C9(BER_TLV_IE, tag=0xC9, nested=UiccSdInstallParams):
pass
def __init__(self, decoded: Optional[dict] = None):
if decoded:
self.decoded = decoded
return
# provide some reasonable defaults for a MNO-SD
self.decoded = OrderedDict()
self.decoded['sd-Header'] = { 'mandated': None, 'identification': None }
self.decoded['instance'] = {
'applicationLoadPackageAID': h2b('A0000001515350'),
'classAID': h2b('A000000251535041'),
'instanceAID': h2b('A000000151000000'),
# Optional: extraditeSecurityDomainAID
'applicationPrivileges': h2b('82FC80'),
# Optioal: lifeCycleState
'applicationSpecificParametersC9': h2b('8201f09301f08701f0'), # we assume user uses add_scp()
# Optional: systemSpecificParameters
'applicationParameters': {
# TAR: B20100, MSL: 12
'uiccToolkitApplicationSpecificParametersField': h2b('0100000100000002011203B2010000'),
},
# Optional: processData
# Optional: controlReferenceTemplate
}
self.decoded['keyList'] = [] # we assume user uses add_key() method for all keys
# Optional: sdPersoData
# Optional: openPersoData
# Optional: catTpParameters
self._post_decode()
def _post_decode(self):
self.usip = self.C9()
self.usip.from_bytes(self.decoded['instance']['applicationSpecificParametersC9'])
self.keys = [SecurityDomainKey.from_saip_dict(x) for x in self.decoded['keyList']]
def _pre_encode(self):
self.decoded['keyList'] = [x.to_saip_dict() for x in self.keys]
self.decoded['instance']['applicationSpecificParametersC9'] = self.usip.to_bytes()
def has_scp(self, scp: int) -> bool:
"""Determine if SD Installation parameters already specify given SCP."""
return self.usip.nested_collection.has_scp(scp)
def add_scp(self, scp: int, i: int):
"""Add given SCP (and i parameter) to list of SCP of the Security Domain Install Params.
Example: add_scp(0x03, 0x70) for SCP03, or add_scp(0x02, 0x55) for SCP02."""
self.usip.nested_collection.add_scp(scp, i)
self._pre_encode()
def remove_scp(self, scp: int):
"""Remove given SCP from list of SCP of the Security Domain Install Params."""
self.usip.nested_collection.remove_scp(scp)
self._pre_encode()
def find_key(self, key_version_number: int, key_id: int) -> Optional[SecurityDomainKey]:
"""Find and return (if any) the SecurityDomainKey for given KVN + KID."""
for k in self.keys:
if k.key_version_number == key_version_number and k.key_identifier == key_id:
return k
return None
def add_key(self, key: SecurityDomainKey):
"""Add a given SecurityDomainKey to the keyList of the securityDomain."""
if self.find_key(key.key_version_number, key.key_identifier):
raise ValueError('Key for KVN=0x%02x / KID=0x%02x already exists' % (key.key_version_number,
key.key_identifier))
self.keys.append(key)
self._pre_encode()
def remove_key(self, key_version_number: int, key_id: int):
key = self.find_key(key_version_number, key_id)
if not key:
raise ValueError('No key for KVN=0x%02x / KID=0x%02x found' % (key_version_number, key_id))
self.keys.remove(key)
self._pre_encode()
class ProfileElementSSD(ProfileElementSD):
"""Class representing a securityDomain ProfileElement for a SSD."""
def __init__(self):
super().__init__()
# defaults [overriding ProfileElementSD) taken from SAIP v2.3.1 Section 11.2.12
self.decoded['instance']['instanceAID'] = h2b('A00000055910100102736456616C7565')
self.decoded['instance']['applicationPrivileges'] = h2b('808000')
self.decoded['instance']['applicationParameters'] = {
# TAR: 6C7565, MSL: 12
'uiccToolkitApplicationSpecificParametersField': h2b('01000001000000020112036C756500'),
}
class ProfileElementUSIM(ProfileElement):
type = 'usim'
@property
def adf_name(self) -> str:
return b2h(self.decoded['adf-usim'][0][1]['dfName'])
@property
def imsi(self) -> Optional[str]:
f = File('ef-imsi', self.decoded['ef-imsi'])
return dec_imsi(b2h(f.stream.getvalue()))
class ProfileElementISIM(ProfileElement):
type = 'isim'
@property
def adf_name(self) -> str:
return b2h(self.decoded['adf-isim'][0][1]['dfName'])
def bertlv_first_segment(binary: bytes) -> Tuple[bytes, bytes]:
"""obtain the first segment of a binary concatenation of BER-TLV objects.
Returns: tuple of first TLV and remainder."""
_tagdict, remainder = bertlv_parse_tag(binary)
length, remainder = bertlv_parse_len(remainder)
tl_length = len(binary) - len(remainder)
tlv_length = tl_length + length
return binary[:tlv_length], binary[tlv_length:]
class ProfileElementSequence:
"""A sequence of ProfileElement objects, which is the overall representation of an eSIM profile."""
def __init__(self):
self.pe_list: List[ProfileElement] = None
self.pe_by_type: Dict = {}
self.pes_by_naa: Dict = {}
def get_pes_for_type(self, tname: str) -> List[ProfileElement]:
"""Return list of profile elements present for given profile element type."""
return self.pe_by_type.get(tname, [])
def get_pe_for_type(self, tname: str) -> Optional[ProfileElement]:
"""Return a single profile element for given profile element type. Works only for
types of which there is only a signle instance in the PE Sequence!"""
l = self.get_pes_for_type(tname)
if len(l) == 0:
return None
assert len(l) == 1
return l[0]
def parse_der(self, der: bytes) -> None:
"""Parse a sequence of PE and store the result in self.pe_list."""
self.pe_list = []
remainder = der
while len(remainder):
first_tlv, remainder = bertlv_first_segment(remainder)
self.pe_list.append(ProfileElement.from_der(first_tlv))
self._process_pelist()
def _process_pelist(self) -> None:
self._rebuild_pe_by_type()
self._rebuild_pes_by_naa()
def _rebuild_pe_by_type(self) -> None:
self.pe_by_type = {}
# build a dict {pe_type: [pe, pe, pe]}
for pe in self.pe_list:
if pe.type in self.pe_by_type:
self.pe_by_type[pe.type].append(pe)
else:
self.pe_by_type[pe.type] = [pe]
def _rebuild_pes_by_naa(self) -> None:
"""rebuild the self.pes_by_naa dict {naa: [ [pe, pe, pe], [pe, pe] ]} form,
which basically means for every NAA there's a lsit of instances, and each consists
of a list of a list of PEs."""
self.pres_by_naa = {}
petype_not_naa_related = ['securityDomain', 'rfm', 'application', 'end']
naa = ['mf', 'usim', 'isim', 'csim']
cur_naa = None
cur_naa_list = []
for pe in self.pe_list:
# skip all PE that are not related to NAA
if pe.type in petype_not_naa_related:
continue
if pe.type in naa:
if cur_naa:
if not cur_naa in self.pes_by_naa:
self.pes_by_naa[cur_naa] = []
self.pes_by_naa[cur_naa].append(cur_naa_list)
cur_naa = pe.type
cur_naa_list = []
cur_naa_list.append(pe)
# append the final one
if cur_naa and len(cur_naa_list) > 0:
if not cur_naa in self.pes_by_naa:
self.pes_by_naa[cur_naa] = []
self.pes_by_naa[cur_naa].append(cur_naa_list)
@classmethod
def from_der(cls, der: bytes) -> 'ProfileElementSequence':
"""Construct an instance from given raw, DER encoded bytes."""
inst = cls()
inst.parse_der(der)
return inst
def to_der(self) -> bytes:
"""Build an encoded DER representation of the instance."""
out = b''
for pe in self.pe_list:
out += pe.to_der()
return out
def renumber_identification(self):
"""Re-generate the 'identification' numbering of all PE headers."""
i = 1
for pe in self.pe_list:
hdr = pe.header
if not hdr:
continue
pe.header['identification'] = i
i += 1
def get_index_by_type(self, petype: str) -> List[int]:
"""Return a list with the indicies of all instances of PEs of petype."""
ret = []
i = 0
for pe in self.pe_list:
if pe.type == petype:
ret.append(i)
i += 1
return ret
def add_ssd(self, ssd: ProfileElementSSD):
"""Add a SSD (Supplementary Security Domain) After MNO-SD/ISD-P."""
# find MNO-SD index
idx = self.get_index_by_type('securityDomain')[0]
# insert _after_ MNO-SD
self.pe_list.insert(idx+1, ssd)
self._process_pelist()
self.renumber_identification()
def remove_naas_of_type(self, naa: Naa) -> None:
"""Remove all instances of NAAs of given type. This can be used, for example,
to remove all CSIM NAAs from a profile. Will not just remove the PEs, but also
any records in 'eUICC-Mandatory-services' or 'eUICC-Mandatory-GFSTEList'."""
hdr = self.pe_by_type['header'][0]
# remove any associated mandatory services
for service in naa.mandatory_services:
if service in hdr.decoded['eUICC-Mandatory-services']:
del hdr.decoded['eUICC-Mandatory-services'][service]
# remove any associaed mandatory filesystem templates
for template in naa.templates:
if template in hdr.decoded['eUICC-Mandatory-GFSTEList']:
hdr.decoded['eUICC-Mandatory-GFSTEList'] = [x for x in hdr.decoded['eUICC-Mandatory-GFSTEList'] if not template.prefix_match(x)]
# determine the ADF names (AIDs) of all NAA ADFs
naa_adf_names = []
if naa.pe_types[0] in self.pe_by_type:
for pe in self.pe_by_type[naa.pe_types[0]]:
adf_name = naa.adf_name()
adf = File(adf_name, pe.decoded[adf_name])
naa_adf_names.append(adf.fileDescriptor['dfName'])
# remove PEs of each NAA instance
if naa.name in self.pes_by_naa:
for inst in self.pes_by_naa[naa.name]:
# delete all the PEs of the NAA
self.pe_list = [pe for pe in self.pe_list if pe not in inst]
self._process_pelist()
# remove any RFM PEs for the just-removed ADFs
if 'rfm' in self.pe_by_type:
to_delete_pes = []
for rfm_pe in self.pe_by_type['rfm']:
if 'adfRFMAccess' in rfm_pe.decoded:
if rfm_pe.decoded['adfRFMAccess']['adfAID'] in naa_adf_names:
to_delete_pes.append(rfm_pe)
self.pe_list = [pe for pe in self.pe_list if pe not in to_delete_pes]
self._process_pelist()
# TODO: remove any records related to the ADFs from EF.DIR
def __repr__(self) -> str:
return "PESequence(%s: %s)" % (self.iccid, ', '.join([str(x) for x in self.pe_list]))
def __iter__(self) -> str:
yield from self.pe_list
@property
def iccid(self) -> Optional[str]:
"""The ICCID of the profile."""
if not 'header' in self.pe_by_type:
return None
if len(self.pe_by_type['header']) < 1:
return None
pe_hdr_dec = self.pe_by_type['header'][0].decoded
if not 'iccid' in pe_hdr_dec:
return None
return b2h(pe_hdr_dec['iccid'])