personalization: implement reading back values from a PES

Implement get_values_from_pes(), the reverse direction of apply_val():
read back and return values from a ProfileElementSequence. Implement for
all ConfigurableParameter subclasses.

Future: SdKey.get_values_from_pes() is reading pe.decoded[], which works
fine, but I07dfc378705eba1318e9e8652796cbde106c6a52 will change this
implementation to use the higher level ProfileElementSD members.

Implementation detail:

Implement get_values_from_pes() as classmethod that returns a generator.
Subclasses should yield all occurences of their parameter in a given
PES.

For example, the ICCID can appear in multiple places.
Iccid.get_values_from_pes() yields all of the individual values. A set()
of the results quickly tells whether the PES is consistent.

Rationales for reading back values:

This allows auditing an eSIM profile, particularly for producing an
output.csv from a batch personalization (that generated lots of random
key material which now needs to be fed to an HLR...).

Reading back from a binary result is more reliable than storing the
values that were fed into a personalization.
By auditing final DER results with this code, I discovered:
- "oh, there already was some key material in my UPP template."
- "all IMSIs ended up the same, forgot to set up the parameter."
- the SdKey.apply() implementations currently don't work, see
  I07dfc378705eba1318e9e8652796cbde106c6a52 for a fix.

Change-Id: I234fc4317f0bdc1a486f0cee4fa432c1dce9b463
This commit is contained in:
Neels Hofmeyr
2025-03-07 23:54:43 +01:00
parent 41641979e2
commit e5f0f07835

View File

@@ -17,15 +17,20 @@
import abc import abc
import io import io
import os
import copy import copy
from typing import List, Tuple, Generator from typing import List, Tuple, Generator, Optional
from osmocom.tlv import camel_to_snake from osmocom.tlv import camel_to_snake
from pySim.utils import enc_iccid, enc_imsi, h2b, rpad, sanitize_iccid, all_subclasses_of from osmocom.utils import hexstr
from pySim.utils import enc_iccid, dec_iccid, enc_imsi, dec_imsi, h2b, b2h, rpad, sanitize_iccid, all_subclasses_of
from pySim.esim.saip import ProfileElement, ProfileElementSequence from pySim.esim.saip import ProfileElement, ProfileElementSequence
from pySim.ts_51_011 import EF_SMSP from pySim.ts_51_011 import EF_SMSP
from pySim.esim.saip import param_source from pySim.esim.saip import param_source
def unrpad(s: hexstr, c='f') -> hexstr:
return hexstr(s.rstrip(c))
def remove_unwanted_tuples_from_list(l: List[Tuple], unwanted_keys: List[str]) -> List[Tuple]: def remove_unwanted_tuples_from_list(l: List[Tuple], unwanted_keys: List[str]) -> List[Tuple]:
"""In a list of tuples, remove all tuples whose first part equals 'unwanted_key'.""" """In a list of tuples, remove all tuples whose first part equals 'unwanted_key'."""
return list(filter(lambda x: x[0] not in unwanted_keys, l)) return list(filter(lambda x: x[0] not in unwanted_keys, l))
@@ -48,6 +53,22 @@ class ClassVarMeta(abc.ABCMeta):
setattr(x, 'name', camel_to_snake(name)) setattr(x, 'name', camel_to_snake(name))
return x return x
def file_tuples_content_as_bytes(l: List[Tuple]) -> Optional[bytes]:
"""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.seek(v, os.SEEK_CUR)
elif k == 'fillFileContent':
stream.write(v)
else:
return ValueError("Unknown key '%s' in tuple list" % k)
return stream.getvalue()
class ConfigurableParameter: class ConfigurableParameter:
r"""Base class representing a part of the eSIM profile that is configurable during the r"""Base class representing a part of the eSIM profile that is configurable during the
personalization process (with dynamic data from elsewhere). personalization process (with dynamic data from elsewhere).
@@ -206,6 +227,30 @@ class ConfigurableParameter:
Write the given val in the right format in all the right places in pes.""" Write the given val in the right format in all the right places in pes."""
pass pass
@classmethod
def get_values_from_pes(cls, pes: ProfileElementSequence) -> Generator:
'''This is what subclasses implement: yield all values from a decoded profile package.
Find all values in the pes, and yield them decoded to a valid cls.input_value format.
Should be a generator function, i.e. use 'yield' instead of 'return'.
Usage example:
cls = esim.saip.personalization.Iccid
# use a set() to get a list of unique values from all results
vals = set( cls.get_values_from_pes(pes) )
if len(vals) != 1:
raise ValueError(f'{cls.name}: need exactly one value, got {vals}')
# the set contains a single value, return it
return vals.pop()
Implementation example:
for pe in pes:
if my_condition(pe):
yield b2h(my_bin_value_from(pe))
'''
pass
@classmethod @classmethod
def get_len_range(cls): def get_len_range(cls):
"""considering all of min_len, max_len and allow_len, get a tuple of the resulting (min, max) of permitted """considering all of min_len, max_len and allow_len, get a tuple of the resulting (min, max) of permitted
@@ -272,6 +317,17 @@ class DecimalHexParam(DecimalParam):
# a DecimalHexParam subclass expects the apply_val() input to be a bytes instance ready for the pes # a DecimalHexParam subclass expects the apply_val() input to be a bytes instance ready for the pes
return h2b(val) return h2b(val)
@classmethod
def decimal_hex_to_str(cls, val):
'useful for get_values_from_pes() implementations of subclasses'
if isinstance(val, bytes):
val = b2h(val)
assert isinstance(val, hexstr)
if cls.rpad is not None:
c = cls.rpad_char or 'f'
val = unrpad(val, c)
return val.to_bytes().decode('ascii')
class IntegerParam(ConfigurableParameter): class IntegerParam(ConfigurableParameter):
allow_types = (str, int) allow_types = (str, int)
allow_chars = '0123456789' allow_chars = '0123456789'
@@ -347,6 +403,17 @@ class Iccid(DecimalParam):
# patch MF/EF.ICCID # patch MF/EF.ICCID
file_replace_content(pes.get_pe_for_type('mf').decoded['ef-iccid'], h2b(enc_iccid(val))) file_replace_content(pes.get_pe_for_type('mf').decoded['ef-iccid'], h2b(enc_iccid(val)))
@classmethod
def get_values_from_pes(cls, pes: ProfileElementSequence):
padded = b2h(pes.get_pe_for_type('header').decoded['iccid'])
iccid = unrpad(padded)
yield iccid
for pe in pes.get_pes_for_type('mf'):
iccid_pe = pe.decoded.get('ef-iccid', None)
if iccid_pe:
yield dec_iccid(b2h(file_tuples_content_as_bytes(iccid_pe)))
class Imsi(DecimalParam): class Imsi(DecimalParam):
"""Configurable IMSI. Expects value to be a string of digits. Automatically sets the ACC to """Configurable IMSI. Expects value to be a string of digits. Automatically sets the ACC to
the last digit of the IMSI.""" the last digit of the IMSI."""
@@ -368,13 +435,21 @@ class Imsi(DecimalParam):
file_replace_content(pe.decoded['ef-acc'], acc.to_bytes(2, 'big')) file_replace_content(pe.decoded['ef-acc'], acc.to_bytes(2, 'big'))
# TODO: DF.GSM_ACCESS if not linked? # TODO: DF.GSM_ACCESS if not linked?
@classmethod
def get_values_from_pes(cls, pes: ProfileElementSequence):
for pe in pes.get_pes_for_type('usim'):
imsi_pe = pe.decoded.get('ef-imsi', None)
if imsi_pe:
yield dec_imsi(b2h(file_tuples_content_as_bytes(imsi_pe)))
class SmspTpScAddr(ConfigurableParameter): class SmspTpScAddr(ConfigurableParameter):
"""Configurable SMSC (SMS Service Centre) TP-SC-ADDR. Expects to be a phone number in national or """Configurable SMSC (SMS Service Centre) TP-SC-ADDR. Expects to be a phone number in national or
international format (designated by a leading +). Automatically sets the NPI to E.164 and the TON based on international format (designated by a leading +). Automatically sets the NPI to E.164 and the TON based on
presence or absence of leading +""" presence or absence of leading +."""
def validate(self): @classmethod
addr_str = str(self.input_value) def validate_val(cls, val):
addr_str = str(val)
if addr_str[0] == '+': if addr_str[0] == '+':
digits = addr_str[1:] digits = addr_str[1:]
international = True international = True
@@ -385,10 +460,14 @@ class SmspTpScAddr(ConfigurableParameter):
raise ValueError('TP-SC-ADDR must not exceed 20 digits') raise ValueError('TP-SC-ADDR must not exceed 20 digits')
if not digits.isdecimal(): if not digits.isdecimal():
raise ValueError('TP-SC-ADDR must only contain decimal digits') raise ValueError('TP-SC-ADDR must only contain decimal digits')
self.value = (international, digits) return (international, digits)
def apply(self, pes: ProfileElementSequence): @classmethod
international, digits = self.value def apply_val(cls, pes: ProfileElementSequence, val):
"""val must be a tuple (international[bool], digits[str]).
For example, an input of "+1234" corresponds to (True, "1234");
An input of "1234" corresponds to (False, "1234")."""
international, digits = val
for pe in pes.get_pes_for_type('usim'): for pe in pes.get_pes_for_type('usim'):
# obtain the File instance from the ProfileElementUSIM # obtain the File instance from the ProfileElementUSIM
f_smsp = pe.files['ef-smsp'] f_smsp = pe.files['ef-smsp']
@@ -411,6 +490,32 @@ class SmspTpScAddr(ConfigurableParameter):
# re-generate the pe.decoded member from the File instance # re-generate the pe.decoded member from the File instance
pe.file2pe(f_smsp) pe.file2pe(f_smsp)
@classmethod
def get_values_from_pes(cls, pes: ProfileElementSequence):
for pe in pes.get_pes_for_type('usim'):
f_smsp = pe.files['ef-smsp']
ef_smsp = EF_SMSP()
ef_smsp_dec = ef_smsp.decode_record_bin(f_smsp.body, 1)
tp_sc_addr = ef_smsp_dec.get('tp_sc_addr', None)
if not tp_sc_addr:
continue
digits = tp_sc_addr.get('call_number', None)
if not digits:
continue
ton_npi = tp_sc_addr.get('ton_npi', None)
if not ton_npi:
continue
international = ton_npi.get('type_of_number', None)
if international is None:
continue
international = (international == 'international')
yield (international, digits)
class SdKey(BinaryParam, metaclass=ClassVarMeta): class SdKey(BinaryParam, metaclass=ClassVarMeta):
"""Configurable Security Domain (SD) Key. Value is presented as bytes.""" """Configurable Security Domain (SD) Key. Value is presented as bytes."""
# these will be set by subclasses # these will be set by subclasses
@@ -443,6 +548,14 @@ class SdKey(BinaryParam, metaclass=ClassVarMeta):
for pe in pes.get_pes_for_type('securityDomain'): for pe in pes.get_pes_for_type('securityDomain'):
cls._apply_sd(pe, value) cls._apply_sd(pe, value)
@classmethod
def get_values_from_pes(cls, pes: ProfileElementSequence):
for pe in pes.get_pes_for_type('securityDomain'):
for key in pe.decoded['keyList']:
if key['keyIdentifier'][0] == cls.key_id and key['keyVersionNumber'][0] == cls.kvn:
if len(key['keyComponents']) >= 1:
yield b2h(key['keyComponents'][0]['keyData'])
class SdKeyScp80_01(SdKey, kvn=0x01, key_type=0x88, permitted_len=[16,24,32]): # AES key type class SdKeyScp80_01(SdKey, kvn=0x01, key_type=0x88, permitted_len=[16,24,32]): # AES key type
pass pass
class SdKeyScp80_01Kic(SdKeyScp80_01, key_id=0x01, key_usage_qual=0x18): # FIXME: ordering? class SdKeyScp80_01Kic(SdKeyScp80_01, key_id=0x01, key_usage_qual=0x18): # FIXME: ordering?
@@ -529,6 +642,14 @@ class Puk(DecimalHexParam):
raise ValueError("input template UPP has unexpected structure:" raise ValueError("input template UPP has unexpected structure:"
f" cannot find pukCode with keyReference={cls.keyReference}") f" cannot find pukCode with keyReference={cls.keyReference}")
@classmethod
def get_values_from_pes(cls, pes: ProfileElementSequence):
mf_pes = pes.pes_by_naa['mf'][0]
for pukCodes in obtain_all_pe_from_pelist(mf_pes, 'pukCodes'):
for pukCode in pukCodes.decoded['pukCodes']:
if pukCode['keyReference'] == cls.keyReference:
yield cls.decimal_hex_to_str(pukCode['pukValue'])
class Puk1(Puk): class Puk1(Puk):
is_abstract = False is_abstract = False
name = 'PUK1' name = 'PUK1'
@@ -566,6 +687,20 @@ class Pin(DecimalHexParam):
raise ValueError('input template UPP has unexpected structure:' raise ValueError('input template UPP has unexpected structure:'
+ f' {cls.get_name()} cannot find pinCode with keyReference={cls.keyReference}') + f' {cls.get_name()} cannot find pinCode with keyReference={cls.keyReference}')
@classmethod
def _read_all_pinvalues_from_pe(cls, pe: ProfileElement):
for pinCodes in obtain_all_pe_from_pelist(pe, 'pinCodes'):
if pinCodes.decoded['pinCodes'][0] != 'pinconfig':
continue
for pinCode in pinCodes.decoded['pinCodes'][1]:
if pinCode['keyReference'] == cls.keyReference:
yield cls.decimal_hex_to_str(pinCode['pinValue'])
@classmethod
def get_values_from_pes(cls, pes: ProfileElementSequence):
yield from cls._read_all_pinvalues_from_pe(pes.pes_by_naa['mf'][0])
class Pin1(Pin): class Pin1(Pin):
is_abstract = False is_abstract = False
name = 'PIN1' name = 'PIN1'
@@ -589,6 +724,14 @@ class Pin2(Pin1):
raise ValueError('input template UPP has unexpected structure:' raise ValueError('input template UPP has unexpected structure:'
+ f' {cls.get_name()} cannot find pinCode with keyReference={cls.keyReference} in {naa=}') + f' {cls.get_name()} cannot find pinCode with keyReference={cls.keyReference} in {naa=}')
@classmethod
def get_values_from_pes(cls, pes: ProfileElementSequence):
for naa in pes.pes_by_naa:
if naa not in ['usim','isim','csim','telecom']:
continue
for pe in pes.pes_by_naa[naa]:
yield from cls._read_all_pinvalues_from_pe(pe)
class Adm1(Pin): class Adm1(Pin):
is_abstract = False is_abstract = False
name = 'ADM1' name = 'ADM1'
@@ -615,6 +758,22 @@ class AlgoConfig(ConfigurableParameter):
raise ValueError('input template UPP has unexpected structure:' raise ValueError('input template UPP has unexpected structure:'
f' {cls.__name__} cannot find algoParameter with key={cls.algo_config_key}') f' {cls.__name__} cannot find algoParameter with key={cls.algo_config_key}')
@classmethod
def get_values_from_pes(cls, pes: ProfileElementSequence):
for pe in pes.get_pes_for_type('akaParameter'):
algoConfiguration = pe.decoded['algoConfiguration']
if len(algoConfiguration) < 2:
continue
if algoConfiguration[0] != 'algoParameter':
continue
if not algoConfiguration[1]:
continue
val = algoConfiguration[1].get(cls.algo_config_key, None)
if val is None:
continue
yield algoConfiguration[1][cls.algo_config_key]
class AlgorithmID(DecimalParam, AlgoConfig): class AlgorithmID(DecimalParam, AlgoConfig):
is_abstract = False is_abstract = False
algo_config_key = 'algorithmID' algo_config_key = 'algorithmID'