Compare commits

..
13 Commits
Author SHA1 Message Date
Neels Hofmeyr 2dfcae73ea esim/http_json_api.py: fixup for text/plain response Content-Type
fixup for 4ba09440bf -- for this to work,
we need to remove the check that the response content type must match
the request content type.

As I am testing invigo fetch-profile, which returns a
text/plain;charset=UTF-8, I realize that completely removing the check
is indeed necessary.

Change-Id: I6429cb9f7bcbab7c07ea66ed45f63ac3a77bf5bd
2026-10-21 06:13:27 +02:00
Neels Hofmeyr 23ac6d9a98 personalization: implement Gid1, Gid2 parameters
Change-Id: Ibd24962fdfe53a260df09d01b4ca1cc6fe2c4c35
2026-10-10 00:10:57 +02:00
Neels Hofmeyr 61537273db personalization.py parameter description tweak
Change-Id: I07a28ecd07461b585068ed16e8e6fa4881747b98
2026-10-10 00:10:57 +02:00
Neels Hofmeyr cb0509fdb4 saip.batch: log parameter errors
Change-Id: I6a46b2dc9018078ab8361226d1e6b50d3b4e1aaa
Jenkins: skip-card-test
2026-10-10 00:10:57 +02:00
Neels Hofmeyr f4e90641e4 debug log for rebuild_mandatory_services()
Change-Id: Ie53c84247a9524a81b475b3b02b5330207c15601
Jenkins: skip-card-test
2026-10-10 00:10:57 +02:00
Neels Hofmeyr a6fbc812a3 ConfigurableParameter.get_typical_input_len: limit to 10 lines
Calling applications can use get_typical_input_len() to decide for the
size of an input field. For very long input, a text input field
typically scrolls. So set an upper limit for get_typical_input_len().

Change-Id: Ia3d79e786f397a02bf2a8fafac5030d1198d9f76
Jenkins: skip-card-test
2026-10-10 00:10:57 +02:00
Neels Hofmeyr e36256fad7 TuakNumberOfKeccak: remove numeric_base to hide random number sources
The purpose of numeric_base is to indicate for "random" values whether
to use decimal or hexadecimal digits. Indicate that TuakNumberOfKeccak
does not make sense to be randomized by not indicating a numeric_base.

Change-Id: I913878e3f05ad1e39ff45da75c67582a6a1f930f
Jenkins: skip-card-test
2026-10-10 00:10:57 +02:00
Neels Hofmeyr e685692818 saip/personalization: add SUCI parameters for SAIP 2.1 (GFM)
Add GfmSuciCalcInfo, GfmSuciRi, and test.

Change-Id: I1b69debf5992aa715171b43b30864dc152dc556f
Jenkins: skip-card-test
2026-10-10 00:10:57 +02:00
Neels Hofmeyr c4e92c7828 SAIP2.1_gfmsuci.der
Change-Id: I69354754013e4a98d86a9a8abdd7a6a36eb95177
Jenkins: skip-card-test
2026-10-10 00:10:57 +02:00
Neels Hofmeyr 6882cca63c saip/personalization: add EuiccMandatoryServiceParam for 3 services
Even though the eUICC-Mandatory-services are always set by
saip.PES.rebuild_mandatory_services(), these new params are useful to
audit and validate the values as present in a given DER.

Change-Id: Icddeb2488c4a024c6ee5afcc1b6c8cc0e436c43c
Jenkins: skip-card-test
2026-10-10 00:10:57 +02:00
Neels Hofmeyr 69f4c1d8f5 personalization: SUCI: ff-pad remaining file size
Change-Id: I9465eac9269e3f76dddd467109f547489a0feb93
Jenkins: skip-card-test
2026-10-10 00:10:57 +02:00
Neels Hofmeyr 3cbeeb7569 saip/personalization: add SUCI parameters for SAIP 2.3
Change-Id: I7c727b10a850651a631211e4f168df5fec1600e5
Jenkins: skip-card-test
2026-10-10 00:10:57 +02:00
Neels Hofmeyr 43a9f65779 personalization: EnumParam: implement as value_map, not enum.IntEnum
Because of (1) and (2) below, using enum.IntEnum is not a good choice
for EnumParam. Use a plain dict as value map.

EnumParam's idea is to be a mapping
    human-readable-value-for-UI -> PES-value
and with enum.IntEnum we...

(1) ...we impose python namespace rules onto
human-readable-value-for-UI: "My Value" is not allowed, only "my_value".
By using enum.IntEnum, we make it impossible to use human readable
values like:
- "SUCI-on" (dash not allowed in python identifier)
- "True" (python keyword not allowed)
- "2" (numeric constant cannot be a python identifier)

(2) ...we limit PES-values to int. The mapping should support any type
that the given PES attribute needs (int, bool, bytes, ...).

Future: upcoming ConfigurableParameters depend on this patch:
- MncLen (labels "2" and "3")
- EuiccMandatoryServiceParam (values True and False)
- EfUstServiceParam like SuciInUsim (labels "SUCI-in-UE" and
  "SUCI-in-USIM")

History: this is near the first version of EnumParam, before it was
modified by fixeria to use enum.IntEnum, as requested by CR. I realize
now that enum.IntEnum will not work for upcoming eSIM parameters.

Change-Id: I690ceccf0ec7ef7067bcaa5cec1303cdaf0f78a4
Jenkins: skip-card-test
2026-10-09 23:51:18 +02:00
7 changed files with 1852 additions and 128 deletions

No files matched your search

+1 -3
View File
@@ -411,14 +411,12 @@ class JsonHttpApiClient():
# SGP.22, section 6.5.1)
if response.status_code != self.api_func.expected_http_status:
raise HttpStatusError(response)
resp_content_type = response.headers.get('Content-Type')
if response.content and not resp_content_type.startswith(req_headers['Content-Type']):
raise HttpHeaderError(response)
if not response.headers.get('X-Admin-Protocol', 'gsma/rsp/v2.unknown').startswith('gsma/rsp/v2.'):
raise HttpHeaderError(response)
# Decode response and return the result back to the caller
if response.content:
resp_content_type = response.headers.get('Content-Type')
if resp_content_type.startswith('application/json'):
output = self.api_func.decode_client(response.json())
elif resp_content_type.startswith('text/plain;charset=UTF-8'):
+9 -2
View File
@@ -1517,8 +1517,11 @@ class ProfileElementHeader(ProfileElement):
def mandatory_service_add(self, service_name):
self.decoded['eUICC-Mandatory-services'][service_name] = None
def mandatory_service_present(self, service_name):
return service_name in self.decoded['eUICC-Mandatory-services'].keys()
def mandatory_service_remove(self, service_name):
if service_name in self.decoded['eUICC-Mandatory-services'].keys():
if self.mandatory_service_present(service_name):
del self.decoded['eUICC-Mandatory-services'][service_name]
else:
raise ValueError("service not in eUICC-Mandatory-services list, cannot remove")
@@ -1774,9 +1777,13 @@ class ProfileElementSequence:
hdr_pe = self.get_pe_for_type('header')
# patch in the 'manual' services from the existing list:
old_svc_set = set()
for old_svc in hdr_pe.decoded['eUICC-Mandatory-services'].keys():
if old_svc in manual_services:
svc_set.add(old_svc)
old_svc_set.add(old_svc)
logger.debug(f"{svc_set=} + {old_svc_set=}")
svc_set = svc_set.union(old_svc_set)
logger.debug(f"{svc_set=}")
hdr_pe.decoded['eUICC-Mandatory-services'] = {x: None for x in svc_set}
def rebuild_mandatory_gfstelist(self):
+9
View File
@@ -20,6 +20,9 @@
import copy
import pprint
import logging
import traceback
import inspect
from typing import Generator, Union
from pySim.esim.saip.personalization import ConfigurableParameter
from pySim.esim.saip import param_source
@@ -27,6 +30,10 @@ from pySim.esim.saip import ProfileElementSequence, ProfileElementSD
from pySim.global_platform import KeyUsageQualifier
from osmocom.utils import b2h
logger = logging.getLogger(__name__)
def _func_():
return inspect.currentframe().f_back.f_code.co_name
# a list of ConfigurableParameter classes and/or ConfigurableParameter class instances
ParamList = list[Union[type[ConfigurableParameter], ConfigurableParameter]]
@@ -121,6 +128,8 @@ class BatchPersonalization:
value = p.param_cls.validate_val(input_value)
p.param_cls.apply_val(pes, value)
except Exception as e:
print(traceback.format_exc())
logger.error('during %s: %r', _func_(), e)
raise ValueError(f'{p.param_cls.get_name()} fed by {p.src.name}: {e}') from e
pes.rebuild_mandatory_services()
+540 -99
View File
@@ -19,19 +19,26 @@ import abc
import enum
import io
import re
import json
from typing import List, Tuple, Generator, Optional
from construct.core import StreamError
from osmocom.tlv import camel_to_snake
from osmocom.utils import hexstr
from pySim.utils import enc_iccid, dec_iccid, enc_imsi, dec_imsi, h2b, b2h, rpad, sanitize_iccid
from pySim.ts_31_102 import EF_AD
from pySim.ts_31_102 import EF_AD, EF_UST, EF_Routing_Indicator, EF_SUCI_Calc_Info, DF_USIM_5GS
from pySim.ts_51_011 import EF_SMSP
from pySim.esim.saip import param_source
from pySim.esim.saip import ProfileElement, ProfileElementSD, ProfileElementSequence
from pySim.esim.saip import ProfileElementHeader
from pySim.esim.saip import SecurityDomainKey, SecurityDomainKeyComponent
from pySim.global_platform import KeyUsageQualifier, KeyType
# optimization: instantiate class instance to get the fid only once.
file_path_df_5gs = bytes.fromhex(DF_USIM_5GS().fid)
fid_ri = bytes.fromhex(EF_Routing_Indicator().fid)
fid_sucici = bytes.fromhex(EF_SUCI_Calc_Info().fid)
def unrpad(s: hexstr, c='f') -> hexstr:
return hexstr(s.rstrip(c))
@@ -226,7 +233,7 @@ class ConfigurableParameter(abc.ABC, metaclass=ClassVarMeta):
"""Same as get_values_from_pes() but expecting a single value.
get_values_from_pes() may return values like this:
[{ 'AlgorithmID': 'Milenage' }, { 'AlgorithmID': 'Milenage' }]
This ensures that all these entries are identical and would return only
Instead, ensure that all these entries are identical and would return only
{ 'AlgorithmID': 'Milenage' }.
This is relevant for any profile element that may appear multiple times in the same PES (only a few),
@@ -290,7 +297,9 @@ class ConfigurableParameter(abc.ABC, metaclass=ClassVarMeta):
May be overridden by subclasses.
This default implementation returns the maximum allowed value length -- a good fit for most subclasses.
'''
return cls.get_len_range()[1] or 16
l = cls.get_len_range()[1] or 16
l = min(10*80, l)
return l
@classmethod
def is_super_of(cls, other_class):
@@ -420,68 +429,70 @@ class BinaryParam(ConfigurableParameter):
class EnumParam(ConfigurableParameter):
"""ConfigurableParameter for named integer enumeration values.
"""ConfigurableParameter for named value enumerations.
Subclasses must define a nested enum.IntEnum named 'Values' listing all valid names and their
integer codes. apply_val() and get_values_from_pes() are not implemented here and this must
be inherited from another mixin."""
class Values(enum.IntEnum):
pass # subclasses override this
Subclasses define an own value_map, and implement their own apply_val() and get_values_from_pes().
"""
value_map = {
# For example:
#'Meaningful label for value 23': 0x23,
# Where 0x23 is a valid value to use for apply_val(), of any valid type.
}
_value_map_reverse = None
@classmethod
def validate_val(cls, val) -> int:
if isinstance(val, int):
try:
return int(cls.Values(val))
except ValueError:
pass
elif isinstance(val, str):
member = cls.map_name_to_val(val, strict=False)
if member is not None:
return member
def validate_val(cls, val):
orig_val = val
enum_val = None
if isinstance(val, str):
enum_name = val
enum_val = cls.map_name_to_val(enum_name)
valid = ', '.join(m.name for m in cls.Values)
raise ValueError(f"{cls.get_name()}: invalid argument: {val!r}. Valid arguments are: {valid}")
# if the str is not one of the known value_map.keys(), is it maybe one of value_map.keys()?
if enum_val is None and val in cls.value_map.values():
enum_val = val
if enum_val not in cls.value_map.values():
raise ValueError(f"{cls.get_name()}: invalid argument: {orig_val!r}. Valid arguments are:"
f" {', '.join(cls.value_map.keys())}")
return enum_val
@classmethod
def map_name_to_val(cls, name: str, strict=True) -> int:
"""Return the integer value for a given enum member name. Performs an exact match first,
then falls back to fuzzy matching (case-insensitive, punctuation-insensitive)."""
try:
return int(cls.Values[name])
except KeyError:
pass
def map_name_to_val(cls, name:str, strict=True):
val = cls.value_map.get(name)
if val is not None:
return val
clean = cls.clean_name_str(name)
for member in cls.Values:
if cls.clean_name_str(member.name) == clean:
return int(member)
clean_name = cls.clean_name_str(name)
for k, v in cls.value_map.items():
if clean_name == cls.clean_name_str(k):
return v
if strict:
valid = ', '.join(m.name for m in cls.Values)
raise ValueError(f"{cls.get_name()}: {name!r} is not a known value. Known values are: {valid}")
raise ValueError(f"Problem in {cls.get_name()}: {name!r} is not a known value."
f" Known values are: {cls.value_map.keys()!r}")
return None
@classmethod
def map_val_to_name(cls, val, strict=False) -> str:
"""Return the enum member name for a given integer value."""
try:
return cls.Values(val).name
except ValueError:
if strict:
raise ValueError(f"{cls.get_name()}: {val!r} ({type(val).__name__}) is not a known value.")
return None
if cls._value_map_reverse is None:
cls._value_map_reverse = dict((v, k) for k, v in cls.value_map.items())
name = cls._value_map_reverse.get(val)
if name:
return name
if strict:
raise ValueError(f"Problem in {cls.get_name()}: {val!r} ({type(val)}) is not a known value."
f" Known values are: {cls.value_map.values()!r}")
return None
@classmethod
def name_normalize(cls, name: str) -> str:
"""Map a (possibly fuzzy) name to its canonical enum member name."""
return cls.Values(cls.map_name_to_val(name)).name
def name_normalize(cls, name:str) -> str:
return cls.map_val_to_name(cls.map_name_to_val(name))
@classmethod
def clean_name_str(cls, val: str) -> str:
"""Strip punctuation and case for fuzzy name comparison.
Treats hyphens and underscores as equivalent (both removed)."""
def clean_name_str(cls, val):
return re.sub('[^0-9A-Za-z]', '', val).lower()
@@ -664,69 +675,57 @@ class SmspTpScAddr(ConfigurableParameter):
class MncLen(EnumParam):
"""MNC length. Sets only the MNC length field in EF.AD (Administrative Data).
Accepted values: integer 2 or 3, digit strings '2' or '3', or enum names 'MNC2'/'MNC3'.
"""
"""MNC length. Must be either 2 or 3. Sets only the MNC length field in EF-AD (Administrative Data)."""
name = 'MNC-LEN'
example_input = '2'
value_map = { '2': 2, '3': 3 }
default_source = param_source.ConstantSource
class Values(enum.IntEnum):
MNC2 = 2
MNC3 = 3
example_input = '2'
@classmethod
def validate_val(cls, val):
if isinstance(val, str) and val.isdigit():
val = int(val)
return super().validate_val(val)
@classmethod
def _get_f_ad(cls, pe: ProfileElement):
if not hasattr(pe, 'files'):
return None
f_ad = pe.files.get('ef-ad', None)
if f_ad and f_ad.body:
return f_ad
return None
@classmethod
def _decode_f_ad(cls, f_ad):
try:
ef_ad_dec = EF_AD().decode_bin(f_ad.body)
except StreamError:
return None
if 'mnc_len' not in ef_ad_dec:
return None
return ef_ad_dec
@classmethod
def apply_val(cls, pes: ProfileElementSequence, val: int):
def apply_val(cls, pes: ProfileElementSequence, val):
"""val must be an int: either 2 or 3"""
for pe in pes.get_pes_for_type('usim'):
f_ad = cls._get_f_ad(pe)
if f_ad is None:
if not hasattr(pe, 'files'):
continue
f_ad = pe.files.get('ef-ad')
if not f_ad:
continue
# decode existing values
ef_ad_dec = cls._decode_f_ad(f_ad)
if ef_ad_dec is None:
if not f_ad.body:
continue
try:
ef_ad = EF_AD()
ef_ad_dec = ef_ad.decode_bin(f_ad.body)
except StreamError:
continue
if 'mnc_len' not in ef_ad_dec:
continue
# change mnc_len
ef_ad_dec['mnc_len'] = val
# re-encode into the File body
f_ad.body = EF_AD().encode_bin(ef_ad_dec)
f_ad.body = ef_ad.encode_bin(ef_ad_dec)
pe.file2pe(f_ad)
@classmethod
def get_values_from_pes(cls, pes: ProfileElementSequence):
for pe in pes.get_pes_for_type('usim'):
f_ad = cls._get_f_ad(pe)
if not hasattr(pe, 'files'):
continue
f_ad = pe.files.get('ef-ad', None)
if f_ad is None:
continue
ef_ad_dec = cls._decode_f_ad(f_ad)
if ef_ad_dec is None:
try:
ef_ad = EF_AD()
ef_ad_dec = ef_ad.decode_bin(f_ad.body)
except StreamError:
continue
mnc_len = ef_ad_dec.get('mnc_len')
yield { cls.name: str(mnc_len) }
mnc_len = ef_ad_dec.get('mnc_len', None)
if mnc_len is None:
continue
yield { cls.name: cls.map_val_to_name(int(mnc_len)) }
class SdKey(BinaryParam):
@@ -1100,17 +1099,17 @@ class AlgorithmID(EnumParam, AlgoConfig):
"""use validate_val() from EnumParam, and apply_val() from AlgoConfig.
In get_values_from_pes(), return enum value names, not raw values."""
name = "Algorithm"
# as in pySim/esim/asn1/saip/PE_Definitions-3.3.1.asn
value_map = {
"Milenage" : 1,
"TUAK" : 2,
"usim-test" : 3,
}
algo_config_key = 'algorithmID'
example_input = "Milenage"
default_source = param_source.ConstantSource
# as in pySim/esim/asn1/saip/PE_Definitions-3.3.1.asn
class Values(enum.IntEnum):
Milenage = 1
TUAK = 2
usim_test = 3 # input 'usim-test' also accepted via fuzzy matching
# EnumParam.validate_val() returns the int values from Values
# EnumParam.validate_val() returns the int values from value_map
@classmethod
def get_values_from_pes(cls, pes: ProfileElementSequence):
@@ -1185,3 +1184,445 @@ class TuakNumberOfKeccak(IntegerParam, AlgoConfig):
max_val = 255
example_input = '1'
default_source = param_source.ConstantSource
numeric_base = None # indicate that this won't need random number sources
class EfUstServiceParam(EnumParam):
"""superclass for EF-UST service flag parameters"""
service_idx = 0
value_map = { 'enabled': True, 'disabled': False }
default_source = param_source.ConstantSource
example_input = sorted(value_map.keys())[0]
@classmethod
def apply_val(cls, pes: ProfileElementSequence, val):
for pe in pes.get_pes_for_type('usim'):
f_ust = pe.files['ef-ust']
ef_ust = EF_UST()
ust = ef_ust.decode_bin(f_ust.body)
ust[cls.service_idx]['activated'] = val
f_ust.body = ef_ust.encode_bin(ust)
pe.file2pe(f_ust)
@classmethod
def get_values_from_pes(cls, pes: ProfileElementSequence):
for pe in pes.get_pes_for_type('usim'):
f_ust = pe.files.get('ef-ust', None)
if not f_ust:
continue
ef_ust = EF_UST()
try:
ust = ef_ust.decode_bin(f_ust.body)
service_flag = ust[cls.service_idx]['activated']
yield { cls.name: cls.map_val_to_name(service_flag) }
except:
pass
class SuciActive(EfUstServiceParam):
"""EF-UST service nr 124: enable or disable the SUCI service."""
service_idx = 124
name = '5G-SUCI-active'
value_map = { 'SUCI-off': False, 'SUCI-on': True }
example_input = 'SUCI-on'
class SuciInUsim(EfUstServiceParam):
"""EF-UST service nr 125: calculate SUCI in UE or in USIM"""
service_idx = 125
name = '5G-SUCI-in-USIM'
value_map = { 'SUCI-in-UE': False, 'SUCI-in-USIM': True }
example_input = 'SUCI-in-USIM'
class SuciRi(ConfigurableParameter):
"""SUCI Routing Indicator as in section 4.4.11.11 of 3GPP TS 31.102"""
name = '5G-SUCI-RI'
allow_chars = '0123456789'
min_len = 1
max_len = 4
allow_types = (str,)
example_input = '0'
default_source = param_source.ConstantSource
KEY_RI = "routing_indicator"
@classmethod
def apply_val(cls, pes: ProfileElementSequence, val):
for pe in pes.get_pes_for_type('df-5gs'):
f_ri = pe.files.get('ef-routing-indicator', None)
if f_ri is None:
continue
ef_ri = EF_Routing_Indicator()
ri = ef_ri.decode_bin(f_ri.body)
ri[cls.KEY_RI] = str(val)
f_ri.body = ef_ri.encode_bin(ri)
pe.file2pe(f_ri)
@classmethod
def get_values_from_pes(cls, pes: ProfileElementSequence):
for pe in pes.get_pes_for_type('df-5gs'):
f_ri = pe.files.get('ef-routing-indicator', None)
if f_ri is None:
continue
ef_ri = EF_Routing_Indicator()
try:
ri = ef_ri.decode_bin(f_ri.body)
yield { cls.name: ri.get(cls.KEY_RI) }
except:
pass
class SuciCalcInfoParameter(ConfigurableParameter):
"""SUCI Calculation Information as in section 4.4.11.8 of 3GPP TS 31.102"""
name = '5G-SUCI-CalcInfo'
default_source = param_source.ConstantSource
allow_types = (str,)
max_len = 4096 # to indicate a large input field to UI renderers
example_input = '{"prot_scheme_id_list": [], "hnet_pubkey_list": []}'
PE_IN_UE = ("df-5gs", "ef-suci-calc-info")
PE_IN_USIM = ("df-saip", "ef-suci-calc-info-usim")
suci_calc_info_pe = None
@classmethod
def validate_val(cls, val):
val = super().validate_val(val)
if not val:
val = "{}"
# check that it is a dict something like
# {
# "prot_scheme_id_list": [
# {"priority": 0, "identifier": 2, "key_index": 1},
# {"priority": 1, "identifier": 1, "key_index": 2},
# ],
# "hnet_pubkey_list": [
# {"hnet_pubkey_identifier": 27,
# "hnet_pubkey": "0472DA71976234CE833A6907425867B82E074D44EF907DFB4B3E21C1C2256EBCD15A7DED52FCBB097A4ED250E036C7B9C8C7004C4EEDC4F068CD7BF8D3F900E3B4"},
# {"hnet_pubkey_identifier": 30,
# "hnet_pubkey": "5A8D38864820197C3394B92613B20B91633CBD897119273BF8E4A6F4EEC0A650"},
# ],
# }
try:
d = json.loads(val)
except json.decoder.JSONDecodeError as e:
raise ValueError(f"Cannot parse SUCI Calc Info: {e}") from e
KEY_PSI_LIST = 'prot_scheme_id_list'
KEY_HPK_LIST = 'hnet_pubkey_list'
KEYS_D = set((KEY_HPK_LIST, KEY_PSI_LIST))
KEYS_PSI = set(('identifier', 'key_index', 'priority'))
KEYS_HPK = set(('hnet_pubkey_identifier', 'hnet_pubkey'))
if not d:
d = { KEY_PSI_LIST: [], KEY_HPK_LIST: [] }
if not (isinstance(d, dict)
and set(d.keys()) == KEYS_D):
raise ValueError(f"Unexpected structure in SUCI Calc Info: expected dict with entries {KEYS_D}")
psi = d.get(KEY_PSI_LIST, None)
if not all((set(e.keys()) == KEYS_PSI) for e in psi):
raise ValueError("Unexpected structure in SUCI Calc Info:"
f" in {KEY_PSI_LIST}, expected dict with entries {KEYS_PSI}")
hpk = d.get(KEY_HPK_LIST, None)
if not all((set(e.keys()) == KEYS_HPK) for e in hpk):
raise ValueError("Unexpected structure in SUCI Calc Info:"
f" in {KEY_HPK_LIST}, expected dict with entries {KEYS_HPK}")
return d
@classmethod
def _apply_suci(cls, pes: ProfileElementSequence, val, pe_type="df-5gs", pe_file="ef-suci-calc-info"):
for pe in pes.get_pes_for_type(pe_type):
f_sucici = pe.files.get(pe_file, None)
if not f_sucici:
continue
ef_sucici = EF_SUCI_Calc_Info()
body = ef_sucici.encode_bin(val)
# 0xff pad up to the existing file size, so that the underlying template doesn't come through
is_size = f_sucici.file_size
pad_n = is_size - len(body)
if pad_n > 0:
body = body + b'\xff' * pad_n
f_sucici.body = body
pe.file2pe(f_sucici)
@classmethod
def apply_val(cls, pes: ProfileElementSequence, val):
cls._apply_suci(pes, val, *cls.suci_calc_info_pe)
@staticmethod
def normalize_sucici(sucici:dict):
"""Normalize the CalcInfo dict so it can be json encoded:
convert bytes to hex strings."""
if not sucici:
sucici = {}
for hnet_pubkey in sucici.get('hnet_pubkey_list', ()):
val = hnet_pubkey['hnet_pubkey']
if isinstance(val, bytes):
val = b2h(val)
hnet_pubkey['hnet_pubkey'] = val
return sucici
@classmethod
def _get_suci(cls, pes: ProfileElementSequence, pe_type="df-5gs", pe_file="ef-suci-calc-info"):
for pe in pes.get_pes_for_type(pe_type):
f_sucici = pe.files.get(pe_file, None)
if not f_sucici:
continue
ef_sucici = EF_SUCI_Calc_Info()
sucici = ef_sucici.decode_bin(f_sucici.body)
# normalize to string (bytes cannot go into json)
sucici = cls.normalize_sucici(sucici)
yield { cls.name: json.dumps(sucici) }
@classmethod
def get_values_from_pes(cls, pes: ProfileElementSequence):
yield from cls._get_suci(pes, *cls.suci_calc_info_pe)
class SuciCalcInfoUe(SuciCalcInfoParameter):
"""SUCI Calculation Information as in section 4.4.11.8 of 3GPP TS 31.102, readable by UE (DF-5GS)"""
name = '5G-SUCI-CalcInfo-UE'
suci_calc_info_pe = SuciCalcInfoParameter.PE_IN_UE
class SuciCalcInfoUsim(SuciCalcInfoParameter):
"""SUCI Calculation Information as in section 4.4.11.8 of 3GPP TS 31.102, readable only by USIM (DF-SAIP)"""
name = '5G-SUCI-CalcInfo-USIM'
suci_calc_info_pe = SuciCalcInfoParameter.PE_IN_USIM
def gfm_find(pes: ProfileElementSequence, file_path:bytes, ef_fid:bytes):
"""look through genericFileManagement PE and return the fmc list with start and end indexes as
(fmc_list, first_idx, after_last_idx)
so that fmc_list[first_idx:after_last_idx] is the slice of file management commands relevant to the given
file_path/ef_fid.
"""
for pe in pes.get_pes_for_type('genericFileManagement'):
path_match = False
creating_fid = False
for fmc in pe.decoded['fileManagementCMD']:
first = None
last = None
for idx in range(len(fmc)):
cmd, arg = fmc[idx]
if cmd == 'filePath':
path_match = (arg == file_path)
if not path_match:
creating_fid = False
elif path_match and cmd == 'createFCP':
creating_fid = (arg.get('fileID') == ef_fid)
if creating_fid:
if first is None:
first = idx
last = idx
first = min(first, idx)
last = max(last, idx)
if first is not None:
yield fmc, first, last + 1
# genericFileManagement 5G params
def pes_get_adf_fid(pes:ProfileElementSequence, naa_name="usim", adf_name="adf-usim"):
adf = pes.get_pe_for_type(naa_name)
return adf.decoded[adf_name][0][1]['fileID']
def mk_adf_df_path(pes, naa:str, adf:str, file_path:bytes) -> bytes:
adf_file_id = pes_get_adf_fid(pes, naa, adf)
return b''.join((adf_file_id, file_path))
def gfm_get_file_content(pes: ProfileElementSequence, naa:str, adf:str, file_path:bytes, ef_fid:bytes) -> bytes:
'''find a given file in the genericFileManagement section, and return the bytes from the first fillFileContent
item.
TODO: implement File.from_gfm() and return the full resulting bytes?
'''
adf_df_path = mk_adf_df_path(pes, naa, adf, file_path)
data = []
for fmc, first_idx, after_last_idx in gfm_find(pes, adf_df_path, ef_fid):
assert fmc[first_idx][0] == 'createFCP'
assert after_last_idx > first_idx
idx = first_idx + 1
while idx < after_last_idx:
if fmc[idx][0] == 'fillFileContent':
data.append(fmc[idx][1])
idx += 1
return data
def gfm_set_file_content(pes: ProfileElementSequence, naa:str, adf:str, file_path:bytes, ef_fid:bytes, file_content:bytes) -> int:
adf_df_path = mk_adf_df_path(pes, naa, adf, file_path)
found = 0
for fmc, first_idx, after_last_idx in gfm_find(pes, adf_df_path, ef_fid):
assert fmc[first_idx][0] == 'createFCP'
assert after_last_idx > first_idx
new_fmc = [
fmc[first_idx],
('fillFileContent', file_content),
]
new_fmc[0][1]['efFileSize'] = bytes((len(file_content), ))
fmc[first_idx:after_last_idx] = new_fmc
found += 1
return found
class GfmSuciRi(SuciRi):
"""SUCI Routing Indicator as in section 4.4.11.11 of 3GPP TS 31.102,
applied via General File Management. Intended for SAIP 2.1 profiles."""
name = 'GFM-5G-SUCI-RI'
@classmethod
def apply_val(cls, pes: ProfileElementSequence, val):
ri = {
"routing_indicator": str(val),
"rfu": "ffff"
}
ef_ri = EF_Routing_Indicator()
found = gfm_set_file_content(pes, 'usim', 'adf-usim', file_path_df_5gs, fid_ri,
ef_ri.encode_bin(ri))
if not found:
raise ValueError(f"No target file found, Cannot apply {cls.name} = {ri}")
data = gfm_get_file_content(pes, 'usim', 'adf-usim', file_path_df_5gs, fid_ri)
val = ef_ri.decode_bin(b''.join(data))
@classmethod
def get_values_from_pes(cls, pes: ProfileElementSequence):
data = gfm_get_file_content(pes, 'usim', 'adf-usim', file_path_df_5gs, fid_ri)
if not data:
return
data = b''.join(data)
if not data:
return
ef_ri = EF_Routing_Indicator()
ri = ef_ri.decode_bin(data)
yield { cls.name: ri.get(cls.KEY_RI) }
class GfmSuciCalcInfoUe(SuciCalcInfoUe):
"""SUCI Calculation Information as in section 4.4.11.8 of 3GPP TS 31.102, readable by UE (DF-5GS),
applied via General File Management. Intended for SAIP 2.1 profiles."""
name = 'GFM-5G-SUCI-CalcInfo-UE'
@classmethod
def apply_val(cls, pes: ProfileElementSequence, val):
if not isinstance(val, dict):
raise ValueError("val should be a dict, after 'val = SuciCalcInfoParameter.validate_val(val)'")
ef_sucici = EF_SUCI_Calc_Info()
body = ef_sucici.encode_bin(val)
gfm_set_file_content(pes, 'usim', 'adf-usim', file_path_df_5gs, fid_sucici,
body)
@classmethod
def get_values_from_pes(cls, pes: ProfileElementSequence):
data = gfm_get_file_content(pes, 'usim', 'adf-usim', file_path_df_5gs, fid_sucici)
if not data:
return
data = b''.join(data)
if not data:
return
ef_sucici = EF_SUCI_Calc_Info()
sucici = ef_sucici.decode_bin(data)
sucici = cls.normalize_sucici(sucici)
yield { cls.name: json.dumps(sucici) }
class EuiccMandatoryServiceParam(EnumParam):
"""superclass for managing items of the ProfileHeader / eUICC-Mandatory-services ServicesList"""
service_name = None
value_map = { 'mandatory': True, 'optional': False }
default_source = param_source.ConstantSource
example_input = sorted(value_map.keys())[0]
@classmethod
def apply_val(cls, pes: ProfileElementSequence, val):
for pe in pes.get_pes_for_type('header'):
assert isinstance(pe, ProfileElementHeader)
if val:
pe.mandatory_service_add(cls.service_name)
else:
# explicitly check to avoid exception when the service is already not-present
if pe.mandatory_service_present(cls.service_name):
pe.mandatory_service_remove(cls.service_name)
@classmethod
def get_values_from_pes(cls, pes: ProfileElementSequence):
for pe in pes.get_pes_for_type('header'):
assert isinstance(pe, ProfileElementHeader)
val = pe.mandatory_service_present(cls.service_name)
yield { cls.name: cls.map_val_to_name(val) }
class EuiccMandatoryServiceGetIdentity(EuiccMandatoryServiceParam):
"""eUICC Mandatory Services: get-identity. The eUICC must be capable of providing a 5G identity using SUCI-CalcInfo
located in the USIM's DF-SAIP, see parameter 5G-SUCI-CalcInfo-USIM."""
name = '5G-eUICC-get-identity'
service_name = 'get-identity'
class EuiccMandatoryServiceProfileA(EuiccMandatoryServiceParam):
"""eUICC Mandatory Services: profile-a-x25519. The eUICC must be able to estblish a 5G identity using an X25519 key,
as provided in a profile-A ("identifier": 1) key in SUCI-CalcInfo located in the USIM's DF-SAIP, see parameter
5G-SUCI-CalcInfo-USIM."""
name = '5G-eUICC-profile-a-x25519'
service_name = 'profile-a-x25519'
class EuiccMandatoryServiceProfileB(EuiccMandatoryServiceParam):
"""eUICC Mandatory Services: profile-b-p256. The eUICC must be able to estblish a 5G identity using a P256 key, as
provided in a profile-B ("identifier": 2) key in SUCI-CalcInfo located in the USIM's DF-SAIP, see parameter
5G-SUCI-CalcInfo-USIM."""
name = '5G-eUICC-profile-b-p256'
service_name = 'profile-b-p256'
class Gid1(BinaryParam):
"""Group Identifier Level 1 (3GPP TS 31.102 4.2.10), an arbitrary binary field defined by the operator."""
name = 'GID1'
gid_ef_name = 'ef-gid1'
@classmethod
def apply_val(cls, pes: ProfileElementSequence, val):
# patch OPT-USIM/EF.GIDn
file_replace_content(pes.get_pe_for_type('opt-usim').decoded[cls.gid_ef_name], h2b(val))
@classmethod
def get_values_from_pes(cls, pes: ProfileElementSequence):
for pe in pes.get_pes_for_type('opt-usim'):
gid_f = pe.files.get(cls.gid_ef_name, None)
if gid_f is not None:
yield { cls.name: b2h(gid_f.body) }
class Gid2(Gid1):
"""Group Identifier Level 2 (3GPP TS 31.102 4.2.11), an arbitrary binary field defined by the operator."""
name = 'GID2'
gid_ef_name = 'ef-gid2'
class Gid1Active(EfUstServiceParam):
"""EF-UST service nr 17: enable or disable GID1 (Group Identifier Level 1)."""
service_idx = 17
name = 'GID1-active'
value_map = { 'inactive': False, 'active': True }
example_input = 'inactive'
class Gid2Active(Gid1Active):
"""EF-UST service nr 18: enable or disable GID2 (Group Identifier Level 2)."""
service_idx = 18
name = 'GID2-active'
Binary file not shown.
+117 -8
View File
@@ -21,6 +21,7 @@ import enum
import io
import sys
import unittest
import json
from importlib import resources
from osmocom.utils import hexstr
from pySim.esim.saip import ProfileElementSequence
@@ -53,16 +54,21 @@ class ConfigurableParameterTest(unittest.TestCase):
def test_parameters(self):
upp_fnames = (
'SAIP2.1_gfmsuci.der',
'TS48v5_SAIP2.1A_NoBERTLV.der',
'TS48v5_SAIP2.3_BERTLV_SUCI.der',
)
class Paramtest:
def __init__(self, param_cls, val, expect_val, expect_clean_val=None):
iff_present_default = False
def __init__(self, param_cls, val, expect_val, expect_clean_val=None, iff_present=None):
self.param_cls = param_cls
self.val = val
self.expect_clean_val = expect_clean_val
self.expect_val = expect_val
if iff_present is None:
iff_present = Paramtest.iff_present_default
self.iff_present = iff_present
param_tests = [
Paramtest(param_cls=p13n.Imsi, val='123456',
@@ -148,7 +154,7 @@ class ConfigurableParameterTest(unittest.TestCase):
Paramtest(param_cls=p13n.AlgorithmID,
val='usim-test',
expect_clean_val=3,
expect_val='usim_test'),
expect_val='usim-test'),
Paramtest(param_cls=p13n.AlgorithmID,
val=1,
@@ -161,7 +167,7 @@ class ConfigurableParameterTest(unittest.TestCase):
Paramtest(param_cls=p13n.AlgorithmID,
val=3,
expect_clean_val=3,
expect_val='usim_test'),
expect_val='usim-test'),
Paramtest(param_cls=p13n.K,
val='01020304050607080910111213141516',
@@ -274,7 +280,102 @@ class ConfigurableParameterTest(unittest.TestCase):
expect_clean_val=3,
expect_val='3'),
]
Paramtest(param_cls=p13n.EuiccMandatoryServiceGetIdentity,
val='mandatory',
expect_clean_val=True,
expect_val='mandatory'),
Paramtest(param_cls=p13n.EuiccMandatoryServiceGetIdentity,
val='optional',
expect_clean_val=False,
expect_val='optional'),
Paramtest(param_cls=p13n.EuiccMandatoryServiceProfileA,
val='mandatory',
expect_clean_val=True,
expect_val='mandatory'),
Paramtest(param_cls=p13n.EuiccMandatoryServiceProfileA,
val='optional',
expect_clean_val=False,
expect_val='optional'),
Paramtest(param_cls=p13n.EuiccMandatoryServiceProfileB,
val='mandatory',
expect_clean_val=True,
expect_val='mandatory'),
Paramtest(param_cls=p13n.EuiccMandatoryServiceProfileB,
val='optional',
expect_clean_val=False,
expect_val='optional'),
]
Paramtest.iff_present_default = True
sucici = {
"prot_scheme_id_list": [
{"priority": 0, "identifier": 2, "key_index": 1},
{"priority": 1, "identifier": 1, "key_index": 2},
],
"hnet_pubkey_list": [
{"hnet_pubkey_identifier": 27,
"hnet_pubkey": "0472da71976234ce833a6907425867b82e074d44ef907dfb4b3e21c1c2256ebcd15a7ded52fcbb097a4ed250e036c7b9c8c7004c4eedc4f068cd7bf8d3f900e3b4"},
{"hnet_pubkey_identifier": 30,
"hnet_pubkey": "5a8d38864820197c3394b92613b20b91633cbd897119273bf8e4a6f4eec0a650"},
],
}
param_tests.extend([
Paramtest(param_cls=p13n.SuciActive, val='SUCI-on',
expect_clean_val=True,
expect_val={'5G-SUCI-active': 'SUCI-on'}),
Paramtest(param_cls=p13n.SuciActive, val='SUCI-off',
expect_clean_val=False,
expect_val={'5G-SUCI-active': 'SUCI-off'}),
Paramtest(param_cls=p13n.SuciInUsim, val='SUCI-in-UE',
expect_clean_val=False,
expect_val={'5G-SUCI-in-USIM': 'SUCI-in-UE'}),
Paramtest(param_cls=p13n.SuciInUsim, val='SUCI-in-USIM',
expect_clean_val=True,
expect_val={'5G-SUCI-in-USIM': 'SUCI-in-USIM'}),
Paramtest(param_cls=p13n.SuciRi, val='123',
expect_clean_val='123',
expect_val={'5G-SUCI-RI': '123'}),
Paramtest(param_cls=p13n.SuciRi, val='0',
expect_clean_val='0',
expect_val={'5G-SUCI-RI': '0'}),
Paramtest(param_cls=p13n.SuciRi, val='9999',
expect_clean_val='9999',
expect_val={'5G-SUCI-RI': '9999'}),
Paramtest(param_cls=p13n.SuciCalcInfoUe,
val=json.dumps(sucici),
expect_clean_val=sucici,
expect_val={'5G-SUCI-CalcInfo-UE': json.dumps(sucici)}),
Paramtest(param_cls=p13n.SuciCalcInfoUsim,
val=json.dumps(sucici),
expect_clean_val=sucici,
expect_val={'5G-SUCI-CalcInfo-USIM': json.dumps(sucici)}),
Paramtest(param_cls=p13n.GfmSuciRi, val='123',
expect_clean_val='123',
expect_val={'GFM-5G-SUCI-RI': '123'}),
Paramtest(param_cls=p13n.GfmSuciRi, val='0',
expect_clean_val='0',
expect_val={'GFM-5G-SUCI-RI': '0'}),
Paramtest(param_cls=p13n.GfmSuciRi, val='9999',
expect_clean_val='9999',
expect_val={'GFM-5G-SUCI-RI': '9999'}),
Paramtest(param_cls=p13n.GfmSuciCalcInfoUe,
val=json.dumps(sucici),
expect_clean_val=sucici,
expect_val={'GFM-5G-SUCI-CalcInfo-UE': json.dumps(sucici)}),
])
Paramtest.iff_present_default = False
for sdkey_cls in (
# thin out the number of tests, as a compromise between completeness and test runtime
@@ -391,6 +492,13 @@ class ConfigurableParameterTest(unittest.TestCase):
found = list((t.param_cls.get_value_from_pes(pes) or {}).values())
testlog.append(f"previous value: {found}")
if t.iff_present and not found:
testlog.append("skipping, param not in template.")
output = "\nskip: " + "\n ".join(testlog)
outputs.append(output)
print(output)
continue
try:
param.apply(pes)
except ValueError as e:
@@ -559,7 +667,7 @@ class TestEnumParam(unittest.TestCase):
def test_validate_by_name_exact(self):
self.assertEqual(p13n.AlgorithmID.validate_val('Milenage'), 1)
self.assertEqual(p13n.AlgorithmID.validate_val('TUAK'), 2)
self.assertEqual(p13n.AlgorithmID.validate_val('usim_test'), 3)
self.assertEqual(p13n.AlgorithmID.validate_val('usim-test'), 3)
def test_validate_by_int(self):
self.assertEqual(p13n.AlgorithmID.validate_val(1), 1)
@@ -572,7 +680,6 @@ class TestEnumParam(unittest.TestCase):
self.assertEqual(p13n.AlgorithmID.validate_val('tuak'), 2)
def test_validate_fuzzy_hyphen_underscore(self):
# 'usim-test' has a hyphen; enum member is 'usim_test' — must fuzzy-match
self.assertEqual(p13n.AlgorithmID.validate_val('usim-test'), 3)
def test_validate_invalid_name(self):
@@ -609,7 +716,7 @@ class TestEnumParam(unittest.TestCase):
def test_map_val_known(self):
self.assertEqual(p13n.AlgorithmID.map_val_to_name(1), 'Milenage')
self.assertEqual(p13n.AlgorithmID.map_val_to_name(2), 'TUAK')
self.assertEqual(p13n.AlgorithmID.map_val_to_name(3), 'usim_test')
self.assertEqual(p13n.AlgorithmID.map_val_to_name(3), 'usim-test')
def test_map_val_unknown_nonstrict(self):
self.assertIsNone(p13n.AlgorithmID.map_val_to_name(99))
@@ -623,7 +730,9 @@ class TestEnumParam(unittest.TestCase):
def test_name_normalize(self):
self.assertEqual(p13n.AlgorithmID.name_normalize('Milenage'), 'Milenage')
self.assertEqual(p13n.AlgorithmID.name_normalize('milenage'), 'Milenage')
self.assertEqual(p13n.AlgorithmID.name_normalize('usim-test'), 'usim_test')
self.assertEqual(p13n.AlgorithmID.name_normalize('usimtest'), 'usim-test')
self.assertEqual(p13n.AlgorithmID.name_normalize('usim_test'), 'usim-test')
self.assertEqual(p13n.AlgorithmID.name_normalize('USIM Test'), 'usim-test')
# --- clean_name_str ---
File diff suppressed because it is too large. Load diff