Compare commits

...

185 Commits

Author SHA1 Message Date
Neels Hofmeyr
70033f1e02 saip: MncLen: fix for missing EF-AD
Change-Id: I7f5ce22d23808c91234c6a75b8a22264d3c5bc92
2026-04-09 22:19:18 +02:00
Neels Hofmeyr
cef761d5e1 saip.batch: log parameter errors
Change-Id: I6a46b2dc9018078ab8361226d1e6b50d3b4e1aaa
2026-04-09 22:19:18 +02:00
Neels Hofmeyr
d6459b79b5 MncLen
Change-Id: I6c600faeab00ffb072acbe94c9a8b2d1397c07d3
2026-04-09 22:19:18 +02:00
Neels Hofmeyr
29c9adb871 sdkeys kv40 aes
Change-Id: If5b53c840ebd1f224f9bb4706a602b415194f47b
2026-04-09 22:19:18 +02:00
Neels Hofmeyr
c543e0e90b esim/http_json_api.py: support text/plain response Content-Type
Allow returning text/plain Content-Types as 'data' output argument.

So far, all the esim/http_json_api functions require a JSON response.
However, a specific vendor has a list function where the request is JSON
but the response is text/plain CSV data. Allow and return in a dict.

Change-Id: Iba6e4cef1048b376050a435a900c0f395655a790
2026-04-09 22:19:18 +02:00
Neels Hofmeyr
6f735745d2 Revert "esim/http_json_api: extend JSON API with server functionality"
This reverts commit e00c0becca.
2026-04-09 22:19:18 +02:00
Neels Hofmeyr
e7dc753b51 Revert "esim/http_json_api: add missing apidoc"
This reverts commit 0a1c5a27d7.
2026-04-09 22:19:18 +02:00
Neels Hofmeyr
fe9a61a1ba Revert "http_json_api: Only require Content-Type if response body is non-empty"
This reverts commit e0a9e73267.
2026-04-09 22:19:18 +02:00
Neels Hofmeyr
da8b3456b3 Revert "esim/http_json_api: add alternative API interface"
This reverts commit f9d7c82b4d.
2026-04-09 22:19:18 +02:00
Neels Hofmeyr
ec728abda1 Revert "esim/http_json_api: add alternative API interface (follow up)"
This reverts commit 8b2a49aa8e.
2026-04-09 22:19:18 +02:00
Neels Hofmeyr
ba53239098 Revert "esim/http_json_api: allow URL rewriting"
This reverts commit 0634f77308.
2026-04-09 22:19:13 +02:00
Neels Hofmeyr
6ed9437b20 saip: add numeric_base indicator to ConfigurableParameter and ParamSource
By default, numeric_base = None, to indicate that there are no explicit
limitations on the number space.

For parameters that are definitely decimal, set numeric_base = 10.
For definitely hexadecimal, set numeric_base = 16.

Do the same for ConfigurableParameter as well as ParamSource, so callers
can match them up: if a parameter is numeric_base = 10, then omit
sources that are numeric_base = 16, and vice versa.

Change-Id: Ib0977bbdd9a85167be7eb46dd331fedd529dae01
2026-04-09 22:17:41 +02:00
Neels Hofmeyr
14d0f5b6e7 saip SmspTpScAddr.get_values_from_pes: allow empty values
Change-Id: Ibbdd08f96160579238b50699091826883f2e9f5a
2026-04-09 22:17:41 +02:00
Neels Hofmeyr
31d74356ff SdKey KVN4X ID02: set key_usage_qual=0x48
Related: SYS#7865
Change-Id: Idc5d33a4a003801f60c95fff6931706a9aeb6692
2026-04-09 22:17:41 +02:00
Neels Hofmeyr
1f76b767d4 saip: SdKey.__doc__: update SdKey listing
Change-Id: Ib5011b0c7d76b082231744cf09077628dc4e69b7
2026-04-09 22:17:41 +02:00
Neels Hofmeyr
872c1a2bc7 esim.saip.personalization: fix TLSPSK keys
Add AES variant of TLSPSK DEK (SCP81 KVN40 key_id=0x02).

Change-Id: I713a008fd26bbfcf437e0f29717b753f058ce76a
2026-04-09 22:17:41 +02:00
Neels Hofmeyr
348dbf63be add comment about not updating existing key_usage_qualifier
Change-Id: Ie23ae5fde17be6b37746784bf1601b4d0874397a
2026-04-09 22:17:41 +02:00
Neels Hofmeyr
66ff1a5766 test_configurable_parameters.py: add tests for new parameters
For:
SmspTpScAddr
MilenageRotation
MilenageXoringConstants
TuakNrOfKeccak

Change-Id: Iecbea14fe31a9ee08d871dcde7f295d26d7bd001
2026-04-09 22:17:41 +02:00
Neels Hofmeyr
c4169161e9 saip: SmspTpScAddr: fix get_values_from_pes
Change-Id: I2010305340499c907bb7618c04c61e194db34814
2026-04-09 22:17:41 +02:00
Neels Hofmeyr
6d698ea4c0 ConfigurableParameter: safer val length check
Change-Id: Ibe91722ed1477b00d20ef5e4e7abd9068ff2f3e4
2026-04-09 22:17:41 +02:00
Neels Hofmeyr
f18bb9d545 UppAudit: better indicate exception cause
Change-Id: I4d986b89a473a5b12ed56b4710263b034876a33e
2026-04-09 22:17:41 +02:00
Neels Hofmeyr
4306a17619 remove transitional name mapping
This reverts commit I974cb6c393a2ed2248a6240c2722d157e9235c33

Now, finally, all SdKey classes have a unified logical naming scheme.

Change-Id: Ic185af4a903c2211a5361d023af9e7c6fc57ae78
2026-04-09 22:17:41 +02:00
Neels Hofmeyr
f18a9b8f02 transitional name mapping
To help existing applications transition to a common naming scheme for
the SdKey classes, offer this intermediate result, where the SdKey
classes' .name are still unchanged as before generating them.

Change-Id: I974cb6c393a2ed2248a6240c2722d157e9235c33
2026-04-09 22:17:41 +02:00
Neels Hofmeyr
8c81530d16 generate sdkey classes from a list
Change-Id: Ic92ddea6e1fad8167ea75baf78ffc3eb419838c4
2026-04-09 22:17:41 +02:00
Neels Hofmeyr
7366d7ffb5 saip SmspTpScAddr: safeguard against decoding error
Reading the TS48 V6.0 eSIM_GTP_SAIP2.1A_NoBERTLV profile results in an
exception [1] in SmspTpScAddr. I have a caller that needs to skip
erratic values instead of raising.

The underlying issue, I presume, is that either the data needs
validation before decode_record_bin(), or decode_record_bin() needs
well-defined error handling.

So far I know only of this IndexError, so, as a workaround, catch that.

[1]
  File "/pysim/pySim/esim/saip/personalization.py", line 617, in get_values_from_pes
    ef_smsp_dec = ef_smsp.decode_record_bin(f_smsp.body, 1)
  File "/pysim/pySim/filesystem.py", line 1047, in decode_record_bin
    return parse_construct(self._construct, raw_bin_data)
  File "/application/venv/lib/python3.13/site-packages/osmocom/construct.py", line 550, in parse_construct
    parsed = c.parse(raw_bin_data, total_len=length, **context)
  File "/application/venv/lib/python3.13/site-packages/construct/core.py", line 404, in parse
    return self.parse_stream(io.BytesIO(data), **contextkw)
           ~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/application/venv/lib/python3.13/site-packages/construct/core.py", line 416, in parse_stream
    return self._parsereport(stream, context, "(parsing)")
           ~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/application/venv/lib/python3.13/site-packages/construct/core.py", line 428, in _parsereport
    obj = self._parse(stream, context, path)
  File "/application/venv/lib/python3.13/site-packages/construct/core.py", line 2236, in _parse
    subobj = sc._parsereport(stream, context, path)
  File "/application/venv/lib/python3.13/site-packages/construct/core.py", line 428, in _parsereport
    obj = self._parse(stream, context, path)
  File "/application/venv/lib/python3.13/site-packages/construct/core.py", line 2770, in _parse
    return self.subcon._parsereport(stream, context, path)
           ~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
  File "/application/venv/lib/python3.13/site-packages/construct/core.py", line 428, in _parsereport
    obj = self._parse(stream, context, path)
  File "/application/venv/lib/python3.13/site-packages/construct/core.py", line 2236, in _parse
    subobj = sc._parsereport(stream, context, path)
  File "/application/venv/lib/python3.13/site-packages/construct/core.py", line 428, in _parsereport
    obj = self._parse(stream, context, path)
  File "/application/venv/lib/python3.13/site-packages/construct/core.py", line 2770, in _parse
    return self.subcon._parsereport(stream, context, path)
           ~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
  File "/application/venv/lib/python3.13/site-packages/construct/core.py", line 428, in _parsereport
    obj = self._parse(stream, context, path)
  File "/application/venv/lib/python3.13/site-packages/construct/core.py", line 820, in _parse
    return self._decode(obj, context, path)
           ~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^
  File "/application/venv/lib/python3.13/site-packages/osmocom/construct.py", line 268, in _decode
    if r[-1] == 'f':
       ~^^^^
  File "/application/venv/lib/python3.13/site-packages/osmocom/utils.py", line 50, in __getitem__
    return hexstr(super().__getitem__(val))
                  ~~~~~~~~~~~~~~~~~~~^^^^^
IndexError: string index out of range

Change-Id: Ic436e206776b81f24de126e8ee0ae8bf5f3e8d7a
2026-04-09 22:17:41 +02:00
Neels Hofmeyr
a9f0e39634 saip/param_source: try to not repeat random values
Change-Id: I4fa743ef5677580f94b9df16a5051d1d178edeb0
2026-04-09 22:17:41 +02:00
Neels Hofmeyr
3d386fd534 use secrets.SystemRandom as secure random nr source
secrets.SystemRandom is defined as the most secure random source
available on the given operating system.

Change-Id: I8049cd1292674b3ced82b0926569128535af6efe
2026-04-09 22:17:41 +02:00
Neels Hofmeyr
dce9a31463 use random.SystemRandom as random nr source (/dev/urandom)
/dev/urandom is somewhat better than python's PRNG

Change-Id: I6de38c14ac6dd55bc84d53974192509c18d02bfa
2026-04-09 22:17:41 +02:00
Neels Hofmeyr
2d6f24e3ba add test_param_src.py
Change-Id: I03087b84030fddae98b965e0075d44e04ec6ba5c
2026-04-09 22:17:41 +02:00
Neels Hofmeyr
6c60ed4459 param_source: allow plugging a random implementation (for testing)
Change-Id: Idce2b18af70c17844d6f09f7704efc869456ac39
2026-04-09 22:17:41 +02:00
Neels Hofmeyr
ee06df4fb2 RandomHexDigitSource: rather return in string format, not bytes
Change-Id: I4e86289f6fb72cbd4cf0c90b8b49538cfab69a7f
2026-04-09 22:17:41 +02:00
Neels Hofmeyr
0dca9664bf personalization: add int as input type for BinaryParameter
Change-Id: I31d8142cb0847a8b291f8dc614d57cb4734f0190
2026-04-09 22:17:41 +02:00
Neels Hofmeyr
16b3643d9f personalization.ConfigurableParameter: fix BytesIO() input
Change-Id: I0ad160eef9015e76eef10baee7c6b606fe249123
2026-04-09 22:17:41 +02:00
Neels Hofmeyr
cb046e1092 add test_configurable_parameters.py
Change-Id: Ia55f0d11f8197ca15a948a83a34b3488acf1a0b4
2026-04-09 22:17:40 +02:00
Neels Hofmeyr
165b7d0a68 ConfigurableParameter: do not magically overwrite the 'name' attribute
Change-Id: I6f631444c6addeb7ccc5f6c55b9be3dc83409169
2026-04-09 22:17:40 +02:00
Neels Hofmeyr
d7598159c1 personalization audit: optionally audit all (unknown) SD keys
By a flag, allow to audit also all Security Domain KVN that we have
*not* created ConfigurableParameter subclasses for.

For example, SCP80 has reserved kvn 0x01..0x0f, but we offer only
Scp80Kvn01, Scp80Kvn02, Scp80Kvn03. So we would not show kvn
0x03..0x0f in an audit.

This patch includes audits of all SD key kvn there may be in the UPP.
This will help to spot SD keys that may already be present in a UPP
template, with unexpected / unusual kvn.

Change-Id: Icaf6f7b589f117868633c0968a99f2f0252cf612
2026-04-09 22:17:40 +02:00
Neels Hofmeyr
5ce39170f7 personalization: implement UppAudit and BatchAudit
Change-Id: Iaab336ca91b483ecdddd5c6c8e08dc475dc6bd0a
2026-04-09 22:17:40 +02:00
Neels Hofmeyr
40940d38bf param_source: allow input val expansion like '0 * 32'
Working with keys, we often generate 4, 8, 16, 32 digit wide random
values. Those then typically have default input values like

 00000000000000000000000000000000

it is hard for humans to count the number of digits. Much easier:

 00*16

Teach the ParamSource subclasses dealing with random values to
understand an expansion like this. Any expansion is carried out before
all other input value handling.

Use this expansion also in the default_value of ConfigurableParameter
subclasses that have a default_source pointing at a ParamSource that now
understand this expansion.

Related: SYS#6768
Change-Id: Ie7171c152a7b478736f8825050305606b5af5735
2026-04-09 22:17:40 +02:00
Neels Hofmeyr
7370126c52 comment in uicc.py on Security Domain Keys: add SCP81
Change-Id: Ib0205880f58e78c07688b4637abd5f67ea0570d1
2026-04-09 22:17:40 +02:00
Neels Hofmeyr
8bc57373c6 personalization: fix SdKey.apply_val() implementation
'securityDomain' elements are decoded to ProfileElementSD instances,
which keep higher level representations of the key data apart from the
decoded[] lists.

So far, apply_val() was dropping binary values in decoded[], which does
not work, because ProfileElementSD._pre_encode() overwrites
self.decoded[] from the higher level representation.

Implement using
- ProfileElementSD.find_key() and SecurityDomainKeyComponent to modify
  an exsiting entry, or
- ProfileElementSD.add_key() to create a new entry.

Before this patch, SdKey parameters seemed to patch PES successfully,
but their modifications did not end up in the encoded DER.

(BTW, this does not fix any other errors that may still be present in
the various SdKey subclasses, patches coming up.)

Related: SYS#6768
Change-Id: I07dfc378705eba1318e9e8652796cbde106c6a52
2026-04-09 22:17:40 +02:00
Neels Hofmeyr
b7e2b33831 personalization: add get_typical_input_len() to ConfigurableParameter
The aim is to tell a user interface how wide an input text field should
be chosen to be convenient -- ideally showing the entire value in all
cases, but not too huge for fields that have no sane size limit.

Change-Id: I2568a032167a10517d4d75d8076a747be6e21890
2026-04-09 22:17:40 +02:00
Neels Hofmeyr
cc36200fdd personalization: make AlgorithmID a new EnumParam
The AlgorithmID has a few preset values, and hardly anyone knows which
is which. So instead of entering '1', '2' or '3', make it work with
prededined values 'Milenage', 'TUAK' and 'usim-test'.

Implement the enum value part abstractly in new EnumParam.

Make AlgorithmID a subclass of EnumParam and define the values as from
pySim/esim/asn1/saip/PE_Definitions-3.3.1.asn

Related: SYS#6768
Change-Id: I71c2ec1b753c66cb577436944634f32792353240
2026-04-09 22:17:40 +02:00
Neels Hofmeyr
84aaccec12 personalization: indicate default ParamSource per ConfigurableParameter
Add default_source class members pointing to ParamSource classes to all
ConfigurableParameter subclasses.

This is useful to automatically set up a default ParamSource for a given
ConfigurableParameter subclass, during user interaction to produce a
batch personalization.

For example, if the user selects a Pin1 parameter, a calling program can
implicitly set this to a RandomDigitSource, which will magically make it
work the way that most users need.

BTW, default_source and default_value can be combined to configure a
matching ParamSource instance:

  my_source = MyParam.default_source.from_str( MyParam.default_value )

Change-Id: Ie58d13bce3fa1aa2547cf3cee918c2f5b30a8b32
2026-04-09 22:17:40 +02:00
Neels Hofmeyr
c506b99107 personalization: allow reading back multiple values from PES
Change-Id: Iecb68af7c216c6b9dc3add469564416b6f37f7b2
2026-04-09 22:17:40 +02:00
Neels Hofmeyr
7dc135f847 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
2026-04-09 22:17:40 +02:00
Neels Hofmeyr
39922ddb97 personalization: add param_source.py, add batch.py
Implement pySim.esim.saip.batch.BatchPersonalization,
generating N eSIM profiles from a preset configuration.

Batch parameters can be fed by a constant, incrementing, random or from
CSV rows: add pySim.esim.saip.param_source.* classes to feed such input
to each of the BatchPersonalization's ConfigurableParameter instances.

Related: SYS#6768
Change-Id: I01ae40a06605eb205bfb409189fcd2b3a128855a
2026-04-09 22:17:40 +02:00
Vadim Yanitskiy
f2567de387 pySim/ts_51_011.py: add multi-record test vector for EF_PL
Change-Id: I9f7a444b18056b1683cbd52a25af950125531746
2026-04-07 22:47:22 +07:00
Vadim Yanitskiy
6b5fa38f14 tests: fix TransRecEF _test_de_encode to operate at file level
Previously, _test_de_encode vectors for TransRecEF subclasses were tested
via decode_record_hex()/encode_record_hex(), i.e. one record at a time,
with the decoded value being a scalar.

Switch test_de_encode_record() in TransRecEF_Test to use decode_hex() /
encode_hex() instead, so that vectors represent whole-file content
(decoded value is a list of records) -- consistent with how LinFixedEF
handles _test_de_encode.  Update all existing vectors accordingly.

Change-Id: I4a9610f9ee39833cd0c90f64f89f5fbdd6f0846d
2026-04-07 22:47:22 +07:00
Vadim Yanitskiy
45220e00d5 filesystem: JsonEditor: offer interactive retry on error
When json.loads() fails (e.g. the user made a syntax mistake), prompt
the user with "Re-open file for editing? [y]es/[n]o:" and loop back to
the editor if they answer 'y' or 'yes'.  If the user declines, return
the original unmodified value so no write is attempted; the temp file
is still cleaned up by __exit__() in that case.

Change-Id: I9161b7becea0d8dfd3f5f740fbb253da2f061a1d
Related: OS#6899
2026-04-07 22:47:22 +07:00
Vadim Yanitskiy
5828c92c66 filesystem: JsonEditor: use NamedTemporaryFile
A plain NamedTemporaryFile is sufficient here: we only need a single
file, not a directory to hold it.  Using NamedTemporaryFile is simpler
(no subdirectory to manage) and gives us a .json suffix for free,
which editors use for syntax highlighting.

Change-Id: If3b0bd0fcc90732407dbd03b9cc883f7abeb948e
2026-04-07 22:47:22 +07:00
Vadim Yanitskiy
5e2fd148f8 filesystem: edit_{binary,record}_decoded: add encode/decode examples
When invoking `edit_binary_decoded` or `edit_record_decoded`, the
temp file opened in the editor now contains the EF's encode/decode
test vectors as //-comment lines below the JSON content, similar to
how 'git commit' appends comments to the commit message template.
The comment block is stripped before JSON parsing on save,
so it has no effect on the written data.

The feature is implemented via a new module-level JsonEditor context
manager class that encapsulates the full edit cycle:

* write JSON + examples to a TemporaryDirectory
* invoke the editor
* read back, strip //-comments, parse and return the result

Change-Id: I5a046a9c7ba7e08a98cf643d5a26bc669539b38f
Related: OS#6900
2026-04-07 15:32:13 +00:00
Vadim Yanitskiy
fc932a2ee9 docs: auto-generate Card Filesystem Reference
Add a Sphinx extension (docs/pysim_fs_sphinx.py) that hooks into the
builder-inited event and generates docs/filesystem.rst before Sphinx
reads any source files.

The generated page contains a hierarchical listing of all implemented
EFs and DFs, organised by application/specification (UICC/TS 102 221,
ADF.USIM/TS 31.102, ADF.ISIM/TS 31.103, SIM/TS 51.011).  For each file,
the class docstring and any _test_de_encode / _test_decode vectors
are included as an encoding/decoding example table.

docs/filesystem.rst is fully generated at build time and is therefore
added to .gitignore.

Add tests/unittests/test_fs_coverage.py that walks all pySim.* modules
and verifies that every CardProfile, CardApplication, and standalone
CardDF subclass with EF/DF children is either listed in the SECTIONS
(and will appear in the docs) or explicitly EXCLUDED.

Change-Id: I06ddeefc6c11e04d7c24e116f3f39c8a6635856f
Related: OS#6316
2026-04-07 15:32:13 +00:00
Philipp Maier
d5aa963caa pysim/pcsc: do not use getProtocol for protocol selection
The documentation of the getProtocol provided by pyscard says:

"Return bit mask for the protocol of connection, or None if no
protocol set. The return value is a bit mask of
CardConnection.T0_protocol, CardConnection.T1_protocol,
CardConnection.RAW_protocol, CardConnection.T15_protocol"

This suggests that the purpose of getProtocol is not to determine
which protocols are supported. Its purpose is to determine which
protocol is currently selected (either through auto selection or
through the explicit selection made by the API user). This means
we are using getProtocol wrong.

So far this was no problem, since the auto-selected protocol
should be a supported protocol anyway. However, the automatic
protocol selection may not always return a correct result (see
bug report from THD-siegfried [1]).

Let's not trust the automatic protocol selection. Instead let's
parse the ATR and make the decision based on the TD1/TD2 bytes).

[1] https://osmocom.org/issues/6952

Related: OS#6952
Change-Id: Ib119948aa68c430e42ac84daec8b9bd542db7963
2026-04-02 12:53:30 +02:00
Vadim Yanitskiy
19245d0d8b docs/conf.py: add autodoc_mock_imports for klein and twisted
The eSIM SM-DP+ server modules (`pySim.esim.es2p`, `pySim.esim.es9p`,
`pySim.esim.http_json_api`) unconditionally import optional server-side
dependencies at module level:

  pySim.esim.es2p          -- from klein import Klein
  pySim.esim.http_json_api -- from twisted.web.server import Request

Both imports fail during a docs build if the packages are absent or
broken, causing three "autodoc: failed to import" warnings and three
missing chapters in the generated manual.

Even when klein and twisted are installed, twisted 23.10.0 (the
version pulled in transitively by smpp.twisted3's `Twisted~=23.10.0`
constraint) is incompatible with Python 3.13+ because twisted.web.http
unconditionally executes `import cgi`, a module that was removed from
the standard library in Python 3.13.

Fix: add `autodoc_mock_imports = ['klein', 'twisted']` to conf.py.
Sphinx inserts mock entries into sys.modules before each autodoc import
attempt, so the modules can be imported and documented without requiring
the real packages to be importable at build time.

Change-Id: I71650466f02a6a6d150650deed167c05d2cb6e64
2026-03-31 19:17:13 +07:00
Vadim Yanitskiy
a786590906 docs/conf.py: silence autosectionlabel duplicate-label warnings
sphinxarg.ext generates generic sub-headings ("Named arguments",
"Positional arguments", "Sub-commands", "General options", ...) for
every argparse command and tool.  These repeat across many files and
trigger large numbers of autosectionlabel duplicate-label warnings.

Two-pronged fix:

* `autosectionlabel_maxdepth = 3` eliminates the depth-4+ warnings
  (sub-headings inside each individual command block).
* `suppress_warnings` per file silences the residual depth-3 collisions
  ("serial reader", "decode_hex", "sub-commands", ...) that still
  appear across tool documentation files.

Cross-references into these generic argparse-generated sections are not
a supported use-case, so suppressing the warnings is appropriate.

Change-Id: I9cdf2a4f6cbd435b16b90ab668205600ffd7c3b0
2026-03-31 19:17:13 +07:00
Philipp Maier
ca8fada7b6 pySim-prog/pySim-read: add pySimLogger and verbose cmdline argument
pySim-prog and pySim-read do not integrate the pySimLogger yet. As we
may add more debug output that should not be visible on normal use, we
should ensure that the pySimLogger is correctly set up.

Change-Id: Ia2fa535fd9ce4ffa301c3f5d6f98c1f7a4716c74
2026-03-31 11:29:46 +00:00
Philipp Maier
c995bb1ec2 pySim-shell/cosmetic: remove semicolon
Change-Id: I629bacd432491211b939fcd2bed554b44ef441bc
2026-03-31 11:29:46 +00:00
Philipp Maier
ee06ab987f PySimLogger: add parameter to set initial log-level/verbosity
When we initialize a new PySimLogger, we always call the setup method
first and then use the set_verbose and set_level method to configure
the initial log level and the initial log verbosity. However, we
initialize the PySimLogger in all our programs the same way and we
end up with the same boilerplate code every time. Let's add a keyword
parameter to the setup method where we can pass our opts.verbose (bool)
parameter so that the setup method can do the work for the main program.

In case the caller wants a different default configuration he still can
call set_verbose and set_level methods as needed.

Change-Id: I4b8ef1e203186878910c9614a1d900d5759236a8
2026-03-31 11:29:46 +00:00
Philipp Maier
a1d3b8f5e8 pySim-read: remove import random
In pySim-read we do not have to compute any random numbers, so
we may remove random from the imports

Change-Id: Iae4ee6aafb339cc682345299b92b4ecd0bbca14e
2026-03-31 11:46:28 +02:00
Vadim Yanitskiy
f7b86e1920 docs/put_key-tutorial: fix three typos
"verifiation"        -> "verification"
  "this is identifies" -> "this identifies" (extra word)
  "and and"            -> "and" (doubled word)

Change-Id: I1ae6b01638cc2c3dd8355ba801f85cc179ca8bd3
2026-03-31 08:26:07 +00:00
Vadim Yanitskiy
2cfb0972df docs/legacy: fix typo "EF,IMSI" -> "EF.IMSI"
Change-Id: I1f246ec008a57b2373ed3f5531ab4166101f4dd0
2026-03-31 08:26:07 +00:00
Vadim Yanitskiy
4215a3bfd3 docs/saip-tool: fix typo "insertaion" -> "insertion"
Change-Id: Ie9c9235ec964a15fab19d6ca5a83b2b1ddf07e7b
2026-03-31 08:26:07 +00:00
Vadim Yanitskiy
b42d417bbe docs/shell: fix copy-paste typos in editor command descriptions
Two adjacent commands (`edit_record_decoded`, `edit_binary_decoded`)
had identical copy-pasted error messages with three typos each:

  "modificatiosn" -> "modifications"
  "us the"        -> "use the"
  "comamdn"       -> "command"

Change-Id: Ie23baba4634e2cc40f81439fb11b102778aed1f6
2026-03-31 08:26:07 +00:00
Vadim Yanitskiy
74ac191ae6 docs/card-key-provider: fix heading levels and typo
The "ADM PIN" and "SCP02 / SCP03" sub-sections of "Field naming" used
the '~' heading character, which Sphinx resolved to level 4 - skipping
level 3 and throwing build ERRORs.  As a result, both sub-sections
had no heading at all.  Change both to '^' (level 3) to match the
other sub-sections in this file.

While at it, fix a typo: "consisting if" -> "consisting of".

Change-Id: Ia56efc7fadcc0fd62e87e63850b929d2f80851ba
2026-03-31 08:26:07 +00:00
Philipp Maier
add4b991b7 transport: change APDU format paradigm
Unfortunately we have mixed up the concept of TPDUs and APDUs in
earlier versions of pySim-shell. This lead to problems with
detecteding the APDU case properly (see also ISO/IEC 7816-3) and
also prevented us from adding support for T=1.

This problem has been fixed long time ago and all APDUs sent from
the pySim-shell code should be well formed and valid according to
ISO/IEC 7816-3.

To ensure that we continue to format APDUs correctly as APDUs (and
not TPDUs) we have added a mechanism to the LinkBase class that
would either raise an exception or print a warning if someone
mistakenly tries to send an APDU that is really a TPDU. Whether a
warning is printed or an exception is raised is controlled via the
apdu_strict member in the LinkBase class, which is false (print
warning only) by default.

The reason why we have implemneted the mechanism this way was
because we wanted to ensure that existing APDU scripts (pySim-shell
apdu command) keep working, even though when those scripts uses
APDUs which are formally invalid.

Sending a TPDU instead of an APDU via a T=0 link will still work
in almost all cases. This is also the reason why this problem
slipped through unnoticed for long time. However, there may still
be subtile problems araising from this practice. The root of the
problem is that it is impossible to distinguish between APDU case
3 and 4 when a TPDU instead of an APDU is sent. However in order
to handle a case 4 APDU correctly we must be able to distinguish
the APDU case correctly to handle the case correctly.
ETSI TS 102 221, section 7.3.1.1.4, clause 4 is very clear about
the fact that not (only) the status word (e.g. 61xx) but the
APDU case is what matters.

To complete the logic in LinkBaseTpdu and to maintain compatibility
(older APDU scripts), we must still be able to switch between the
'apdu_strict' mode and the non-strict mode. However, since
pySim-shell, pySim-prog and pySim-read internally use proper APDUs,
we may enable the 'apdu_strict' mode by default.

At the same time we will limit the effect of pySim-shell's
apdu_strict setable to the apdu command only. By doing so, the
bahviour of the apdu command is not altered. Users will still
have to enable the 'strict' mode explicitly. At the same time
all the internal functionality of pySim-shell will always use
the 'strict' mode.

Related: OS#6970
Change-Id: I9a531a825def318b28bf58291d811cf119003fab
2026-03-30 10:26:13 +00:00
Philipp Maier
8c81e2cdf9 docs/put_key: add tutorial that explains how to manage global platform keys
With the increased interest in using GlobalPlatform features of
UICC and eUICCs (OTA-SMS, applets, etc.), also comes an increased
interest in how the related GlobalPlatform keys can be managed
(key rotation, adding/removing keysets from/to a Security Domain).

Unfortunately, many aspects of this topic are not immediately
obvious for the average user. Let's add a tutorial that contains
some practical examples to shine some light on the topic.

Related: SYS#7881
Change-Id: I163dfedca3df572cb8442e9a4a280e6c5b00327e
2026-03-25 18:07:32 +00:00
Vadim Yanitskiy
d9d62ee729 global_platform: refactor gen_install_parameters()
gen_install_parameters() had contradictory logic: the outer guard
required all three arguments to be non-None/non-empty (making them
mutually inclusive), while the inner checks then treated each one
as optional.

Make each parameter independently optional (defaulting to None) and
remove the all-or-nothing check.  Simplify the function body to a
straightforward single-pass construction of system_specific_params.

Change-Id: I8756fb38016cdf0527fe2e21edb44381d1dc557f
2026-03-25 18:05:30 +00:00
Vadim Yanitskiy
c7e68e1281 global_platform: install_cap_parser: argument groups cannot be nested
pySim-shell currently does not work on systems with Python 3.14+:

  File ".../pysim/pySim/global_platform/__init__.py", line 868, in AddlShellCommands
    install_cap_parser_inst_prm_g_grp = install_cap_parser_inst_prm_g.add_argument_group()
  File "/usr/lib/python3.14/argparse.py", line 1794, in add_argument_group
    raise ValueError('argument groups cannot be nested')
  ValueError('argument groups cannot be nested')

The problem is that install_cap_parser creates a nested group inside
of mutually exclusive group.  argparse never supported group nesting
properly, so it has been deprecated since Python 3.11, and eventually
got removed in Python 3.14.

Remove group nesting, adjust the usage string, and implement the
mutual exclusiveness enforcement manually in do_install_cap().

Change-Id: Idddf72d5a745345e134b23f2f01e0257d0667579
2026-03-25 18:05:30 +00:00
Philipp Maier
969f9c0e4b pySim/EF.SMSP: fix encoding of TP-Destination Address
The TP-Destination Address in EF.SMSP uses the same encoding as the
TS-Service Centre Address field. However, even though the encoding
of both fields looks almost identical, it actually isn't.

The TS-Service Centre Address field encodes the length field as
octets required for the call_number + one octet for ton_npi.
(see also: 3GPP TS 24.011, section 8.2.5.2)

The TP-Destination Address uses the number of digits of the
call_number directly in the length field.
(see also: 3GPP TS 23.040, section 9.1.2.5)

Related: SYS#7765
Change-Id: I55c123c9e244e5a6e71a0348f5d476ef03e618e8
2026-03-25 15:34:56 +01:00
Philipp Maier
2ef9abf23e pySim/EF.SMSP: add an additional de_encode test for EF_SMSP
Let's add another testvector where we test what happens when we populate
none of the fields except for the tp_sc_addr.

Related: SYS#7765
Change-Id: I12b600ab17d1acfdddaffe6006095acf1a4228c9
2026-03-24 15:55:24 +00:00
Philipp Maier
473f31066c pySim/pcsc/cosmetic: reformat comment
Change-Id: Ic04bdfbc6727cc670679c377c1afd1de53504b8f
2026-03-23 19:03:19 +00:00
Philipp Maier
b59363b49e pySim/EF.SMSP: remove superflous line break
Change-Id: Ie02e02546e708e2c339810812188bd8e8af2a720
2026-03-23 16:44:38 +01:00
Vadim Yanitskiy
115b517c6a esim/saip: raise an exception properly
Change-Id: Ia3749c02120fdc16e556214d0461cbeca032447b
2026-03-20 14:32:59 -07:00
Vadim Yanitskiy
99aef1fecf cdma_ruim: fix inaccurate comment for EF_AD
Change-Id: I71ea27fd30e44685ff35f49843072ca392995973
2026-03-20 14:32:59 -07:00
Vadim Yanitskiy
caddd1c7a0 ts_31_102: EF_5G_PROSE_UIR: fix copy-pasted inner class name
Change-Id: I460e5ad70f35026d0d794271a4aef17323c14dfb
2026-03-20 14:32:59 -07:00
Vadim Yanitskiy
11a7a7e3b1 ts_31_102: fix description for EF_5GS3GPPLOCI
Change-Id: I9cf3adfce65090fedb3f0fd33c9b3d15a2c5fb8c
2026-03-20 14:32:59 -07:00
Vadim Yanitskiy
5138208ee6 ts_51_011: EF.EXT[6-7]: fix typo in desc
Change-Id: I93df1c9fd8a4d588ed7ed19ec2dc1d304412fc3d
2026-03-20 14:32:59 -07:00
Vadim Yanitskiy
5b2fabde62 utils: DataObjectCollection.encode(): fix TypeError
`members_by_name` is a plain dictionary.  Calling it with `()` raises:

  TypeError: 'dict' object is not callable

Change-Id: I7e0c09aa7303f1506fe3a025fdc3779919dd0e6c
2026-03-20 14:32:59 -07:00
Vadim Yanitskiy
24127e985a utils: dec_plmn(): remove redundant call
Change-Id: Ic95c3992ed57eb8fee952ec2dc7f092dd7689579
2026-03-20 14:32:59 -07:00
Vadim Yanitskiy
09ae327f8b ota: OtaAlgo{Crypt,Auth}: fix algo_auth vs algo_crypt
* OtaAlgoCrypt.from_keyset() searches by `otak.algo_crypt`
  but the error message prints `otak.algo_auth`.  Should be
  `otak.algo_crypt` instead.

* OtaAlgoAuth.__init__() checks `algo_auth` but the error message
  prints `algo_crypt`.  Should be `otak.algo_auth` instead.

Change-Id: Ia636fffaeadc68e3f6d5b65d477e753834c95895
2026-03-20 14:32:59 -07:00
Vadim Yanitskiy
d32bce19f6 sms: fix flags_construct in SMS_DELIVER
* field `tp_rp` appears at bit positions 7 and 5
** bit 7 should be `tp_rp` (Reply Path)
** bit 5 should be `tp_sri` (Status Report Indication)
* field `tp_lp` is completely missing
** should be at bit position 3

Change-Id: I0274849f0fa07281b5e050af429ffda7d249f9e8
2026-03-20 14:32:59 -07:00
Vadim Yanitskiy
83bfdc0d3b ara_m: fix undefined variable used in a format-string
Change-Id: I310a5d461bae2b5e4d8e07097000b079c23aa0f6
2026-03-20 14:32:59 -07:00
Vadim Yanitskiy
14ec52a06c ara_m: fix exceptions not being raised properly
Exceptions are meant to be thrown/raised, not returned.

Change-Id: Id799c264447e22887edcd2dc7eb991cf0af1bbfc
2026-03-20 14:32:59 -07:00
Vadim Yanitskiy
209d13e233 global_platform: fix docstring for Scp03SessionKeys._get_icv()
Change-Id: I8983bc27f581295544360ba8b4ae1d28b3ea850f
2026-03-20 14:32:59 -07:00
Vadim Yanitskiy
3b50e64c8b global_platform: fix s/GET/STORE/ DATA in docs
Both `do_store_data` and `store_data` have identical docstrings that
incorrectly describe the command as GET DATA.  Should be "STORE DATA".
Take a chance to fix missing space between `v2.3` and `Section`.

Change-Id: I33fc80ab8ca50fadc38217b0005eec6169c8e34e
2026-03-20 14:32:59 -07:00
Vadim Yanitskiy
b76cc80ea1 global_platform: fix store_data() returning last chunk only
The loop builds up `response` across multiple STORE DATA blocks,
but the function returns only `data` - the response from the
*last* block.  It should return the accumulated response instead.

Change-Id: I3e15c8004d1e366e8c3896e559656622f48bb1a2
2026-03-20 14:32:59 -07:00
Vadim Yanitskiy
3b87ba3cba global_platform: fix typo in ApplicationTemplate
The keyword argument should be `nested=`.  As written `ApplicationAID`
is silently ignored - `ApplicationTemplate` will not descend into its
nested TLVs.

Change-Id: If45dbb0c9b09fe53560d109957ce339267a9f2b0
2026-03-20 14:32:59 -07:00
Vadim Yanitskiy
ea1d5af383 global_platform: fix typo in SupportedTlsCipherSuitesForScp81
The attribute name is misspelled.  The BER-TLV infrastructure looks
for `_construct`; this typo means `SupportedTlsCipherSuitesForScp81`
will never decode its content.

Change-Id: I0f637951b0eeb7eca2a8b543baa737f216a935ed
2026-03-20 14:32:59 -07:00
Philipp Maier
0634f77308 esim/http_json_api: allow URL rewriting
The URL used when HTTP requests are performed is defined statically
with the url_prefix passed to the constructor of JsonHttpApiClient
together with the path property in JsonHttpApiFunction.

For applications that require dynamic URLs there is no way to rewrite
the URL. Let's add a mechanism that allows API users to apply custom
URL reqriting rules by adding a rewrite_url method to
JsonHttpApiFunction. API users may then overload this method with a
custom implementation as needed.

Related: SYS#7918
Change-Id: Id2713a867079cc140517fe312189e5e2162608a5
2026-03-17 11:17:12 +01:00
Vadim Yanitskiy
a5a5865c7c cdma_ruim: fix copy-pasted desc for EF.AD
Change-Id: I2338f35c21978dd6b8916c0abd57b94f5e087655
2026-03-10 17:43:10 +00:00
Harald Welte
3752aeb94e pySim.esim.saip.File: Support pinStatusTemplateDO + lcsi
The tuples defining a DF or ADF in an eSIM template must contain a
pinStatusTemplateDO.  When parsing tuples into a File() instance, we
must save it, and re-create it at the time we re-encode that file.

Same applies to the lcsi (life cycle state indicator), which may
optionally exist for any file.

Change-Id: I073aa4374f2cd664d07fa0224bf0d4c809cdf4aa
Closes: OS#6955
2026-03-10 16:34:48 +00:00
Philipp Maier
914abe3309 docs/smpp-ota-tool: Add documentation/tutorial
We already have documentation that explains how to run pySim-smpp2sim.
With smpp-ota-tool we now have a counterpart for pySim-smpp2sim, so
let's add documentation for this tool as well.

Related: SYS#7881
Change-Id: If0d18a263f5a6dc035b90f5c5c6a942d46bbba49
2026-03-10 09:23:03 +00:00
Philipp Maier
84754b6ebb contrib/smpp-ota-tool: define commandline arguments in global scope
The commandline arguments are currently defined under __main__ in a
private scope. From there they are not reachable to the sphinx
argparse module. We have to define the arguments globally at the
top. (like in the other applications)

Related: SYS#7881
Change-Id: I2d9782e3f5b1cac78c22d206fdcac4118c7d5e7c
2026-03-10 09:23:03 +00:00
Philipp Maier
c47005d408 contrib/smpp-ota-tool: use '-' instead of '_' in command line args
Some commandline arguments have an underscore in their name. Let's
replace those with dashes.

Change-Id: Icbe9d753d59263997e9ca34d46ed0daca36ca16c
Related: SYS#6868
2026-03-10 09:23:03 +00:00
Philipp Maier
2dfaac6e4f contrib/smpp-ota-tool: fix description string (copy+paste error)
Change-Id: I559844bfa1ac372370ef9d148f2f8a6bf4ab4ef5
Related: SYS#6868
2026-03-10 09:23:03 +00:00
Philipp Maier
a615ba5138 tests/pySim-smpp2sim_test: add testcases for AES128 and AES256
Extend the existing test script so that it can handle multiple
testcases. Also add support for switching eUICC profiles.
Finally, add a testcases to test OTA-SMS (RFM) with AES128 and
AES256 encryption.

Change-Id: I1f10504f3a29a8c74a17991632d932819fecfa5a
Related: OS#6868
2026-03-10 09:23:03 +00:00
Philipp Maier
8ee10ab1a5 tests/pySim-smpp2sim_test/card_sanitizer: update card backup with new test keyset
In our test setup we run the card_sanitizer.py script regualary to ensure that
we have consistent start conditions when running our tests. In case a testcase
crashes for some reason and leaves messed up files on a test card. The
card_sanitizer.py script will ensure that any problem like that is cleaned up
over night.

For the testcases we are about to add in the patch following this one, we need
to provision a new test keyset to one of our test cards. This has been already
done manually. However since the card_sanitizer still has the old keys in its
backup we will have to update that as well.

Change-Id: I5aa8a413b19b3e43a79d03e904daab50b4b1e767
Related: OS#6868
2026-03-10 09:23:03 +00:00
Philipp Maier
f10af30aed global_platform/scp: fix dek_encrypt/dek_decrypt for SCP02
The methods dek_encrypt/dek_decrypt use the wrong algorithm and the
wrong key material. The algorithm should be 3DES rather then single
DES and the key must be the DEK session key instead of the static
DEK key from which the DEK session key is derived.

Related: SYS#7902
Change-Id: I3d0cc7378680b346fa39152c8b7074446d2c869d
2026-03-06 15:51:19 +01:00
Neels Hofmeyr
d8f3c78135 SmspTpScAddr: set example_input
Change-Id: Ie2c367788215d746807be24051478f0032a19448
2026-03-04 00:24:02 +01:00
Neels Hofmeyr
6b9b46a5a4 MilenageRotationConstants: set example_input to 3GPP default
Change-Id: I36a9434b2f96d26d710f489d5afce1f0ef05bba1
2026-03-04 00:23:41 +01:00
Philipp Maier
b6b4501e37 contrib/smpp-ota-tool: fix boolean commandline parameters
Boolean parameters should be false by default and use store_true when
set.

Change-Id: I0652b48d2ea5efbaaf5bc147aa8cef7ab8b0861d
Related: OS#6868
2026-02-24 09:52:48 +01:00
Philipp Maier
54658fa3a9 contrib/smpp-ota-tool: add missing usage helpstrings
Change-Id: Ic1521ba11b405f311a30fdb3585ad518375669ae
Related: OS#6868
2026-02-24 09:52:48 +01:00
Philipp Maier
eb04bb1082 contrib/smpp-ota-tool: warn about mixed up KIC/KIC indexes
Cards usually have multiple sets of KIC, KID (and KIK). The keys
are selected through an index. However, mixing keys from different
sets is concidered as a security violation and cards should reject
such configurations.

Let's print a warning to make users aware that something is off.

Change-Id: Ieb4e14145baba1c2cb4a237b612b04694940f402
Related: OS#6868
2026-02-24 09:52:48 +01:00
Philipp Maier
453fde5a3a contrib/smpp-ota-tool: use correct kid index
(normally KID index and KIC index should be the same since mixing keys
is a concidered as a security violation. However, in this tool we
want to allow users to specify different indexes for KIC and KIC so that
they can make tests to make sure their cards correctly reject mixed up
key indexes)

Change-Id: I8847ccc39e4779971187e7877b8902fca7f8bfc1
Related: OS#6868
2026-02-17 15:24:25 +01:00
Philipp Maier
57237b650e contrib/smpp-ota-tool/cosmetic: use lazy formatting for logging
Change-Id: I2540472a50b7a49b5a67d088cbdd4a2228eef8f4
Related: OS#6868
2026-02-17 15:24:25 +01:00
Philipp Maier
1f94791240 contrib/smpp-ota-tool/cosmetic: fix sourcecode formatting
Change-Id: Icbce41ffac097d2ef8714bc8963536ba54a77db2
Related: OS#6868
2026-02-17 15:24:25 +01:00
Philipp Maier
1a28575327 pySim-shell_test/euicc: ensure test-profile is enabled
When testing commands like get_profile_info, enable_profile,
disable_profile or the commands to manage notifications, we
should ensure that the correct profile is enabled before
executing the actual testcase.

Change-Id: Ie57b0305876bc5001ab3a9c3a3b5711408161b74
2026-02-17 09:22:42 +00:00
Neels Hofmeyr
e7016b5b57 compile_asn1_subdir: filter compiled files by .asn suffix
When I open the .asn file in vim, pySim should not attempt to read the
vim .swp file as asn.1.

	  File "/home/moi/osmo-dev/src/pysim/pySim/esim/saip/__init__.py", line 45, in <module>
	    asn1 = compile_asn1_subdir('saip')
	[...]
	  File "<frozen codecs>", line 325, in decode
	UnicodeDecodeError: 'utf-8' codec can't decode byte 0xad in position 21: invalid start byte

Related: OS#6937
Change-Id: I37df3fc081e51e2ed2198876c63f6e68ecc8fcd8
2026-02-10 16:14:14 +00:00
Philipp Maier
e80f3160a9 pySim/euicc: fix encoding/decoding of Iccid
The class Iccid uses a BcdAdapter to encoded/decode the ICCID. This
works fine for ICCIDs that have an even (20) number of digits. In case
the digit count is odd (19), the ICCID the last digit requires padding.

Let's switch to PaddedBcdAdapter for encoding/decoding, to ensure that
odd-length ICCIDs are padded automatically.

Change-Id: I527a44ba454656a0d682ceb590eec6d9d0ac883a
Related: OS#6868
2026-02-10 13:26:45 +00:00
Neels Hofmeyr
917ad7f9f5 gitignore: fix vim swp file pattern
Change-Id: I5a8351dc09f6ca7c8e9032ff8352e5cf1a4833a3
2026-02-10 13:10:17 +00:00
Philipp Maier
8b2a49aa8e esim/http_json_api: add alternative API interface (follow up)
This is a follow up patch to change:
I2a5d4b59b12e08d5eae7a1215814d3a69c8921f6

- do not ignore length of kwargs
- fix role parameter (roles other than 'legacy_client' can be used now)
- use startswith instead of match

Related: SYS#7866
Change-Id: Ifae13e82d671ff09bddf771f063a388d2ab283eb
2026-02-10 13:42:44 +01:00
Harald Welte
7ee7173a2f pySim.esim.saip.personalization: Fix docstring errors + warnings
pysim/pySim/esim/saip/personalization.py:docstring of pySim.esim.saip.personalization.ConfigurableParameter:27: ERROR: Unexpected indentation. [docutils]
pysim/pySim/esim/saip/personalization.py:docstring of pySim.esim.saip.personalization.ConfigurableParameter:29: WARNING: Block quote ends without a blank line; unexpected unindent. [docutils]
pysim/pySim/esim/saip/personalization.py:docstring of pySim.esim.saip.personalization.ConfigurableParameter:34: ERROR: Unexpected indentation. [docutils]
pysim/pySim/esim/saip/personalization.py:docstring of pySim.esim.saip.personalization.ConfigurableParameter:35: WARNING: Block quote ends without a blank line; unexpected unindent. [docutils]
pysim/pySim/esim/saip/personalization.py:docstring of pySim.esim.saip.personalization.ConfigurableParameter:52: ERROR: Unexpected indentation. [docutils]
pysim/pySim/esim/saip/personalization.py:docstring of pySim.esim.saip.personalization.ConfigurableParameter:53: WARNING: Block quote ends without a blank line; unexpected unindent. [docutils]

Change-Id: I3918308856c3a1a5e6e90561c3e2a6b88040670d
2026-02-09 12:50:47 +00:00
Harald Welte
0f99598b34 pySim.esim.saip.personalization: Fix docstring error
pySim/esim/saip/personalization.py:docstring of pySim.esim.saip.personalization.MilenageXoringConstants:4: ERROR: Unexpected indentation. [docutils]

Change-Id: If6ae360b7f74c095fa9075ae9aa988440496e6de
2026-02-09 12:50:47 +00:00
Harald Welte
d7901ef08d pysim.utils.decomposeATR: Fix docutils warning
pySim/utils.py:docstring of pySim.utils.decomposeATR:9: WARNING: Block quote ends without a blank line; unexpected unindent. [docutils]

Change-Id: Ifda4ba15014ba97634fd5bd5c9b19d9110f4670e
2026-02-09 12:50:47 +00:00
Harald Welte
edfac26824 pySim.esim.saip: Fix docstring warnings:
this fixes the following two warnings:

pySim/esim/saip/__init__.py:docstring of pySim.esim.saip.FsNode.walk:1: WARNING: Inline strong start-string without end-string. [docutils]
pySim/esim/saip/__init__.py:docstring of pySim.esim.saip.FsNodeDF.walk:1: WARNING: Inline strong start-string without end-string. [docutils]

Change-Id: Id7debf9296923b735f76623808cee68967a1ece7
2026-02-09 12:50:47 +00:00
Harald Welte
07a3978748 es2p.py: also allow 18 digit ICCID
While at it, also use tuples (const) instead of lists (var).

Tweaked-by: nhofmeyr@sysmocom.de (docstring, tuples)
Change-Id: Iaa6e710132e3f4c6cecc5ff786922f6c0fcfb54e
2026-02-09 12:46:03 +00:00
Vadim Yanitskiy
a297cdba73 ModemATCommandLink: fix SyntaxWarning: invalid escape sequence '\+'
Change-Id: If8de5299a4dc5a8525ef6657213db95d30e3c83b
Fixes: OS#6948
2026-02-09 12:44:41 +00:00
Philipp Maier
f9d7c82b4d esim/http_json_api: add alternative API interface
unfortunately the API changes introduced in change

I277aa90fddb5171c4bf6c3436259aa371d30d092

broke the API interface of http_json_api.py. This was taken into
account and necessary to introduce add the server functionality next
to the already existing client functionality. The changes to the API
were minimal and all code locations that use http_json_api.py
were re-aligned.

Unfortunately it was not clear at this point in time that there are
out-of-tree projects that could be affected by API changes in
http_json_api.py

To mitigate the problem this patch introduces an alternative API
interface to the JsonHttpApiFunction base class. This alternative
API interface works like the old API interface when the class is
instantiated in the original way. To make use of the revised client
the API use has to pass an additional keyword argument that defines
the role.

Related: SYS#7866
Change-Id: I2a5d4b59b12e08d5eae7a1215814d3a69c8921f6
2026-02-09 12:42:28 +00:00
Alexander Couzens
c6fa2b4007 saip-tool: rename parser_tree correctly
parser_info is already defined and this seems to be a copy/paste
accident.

Change-Id: Icc30dbf02a266211fa4d3aee8e7cec14185e716c
2026-02-09 12:34:35 +00:00
Philipp Maier
39d744010a pySim-shell_test/euicc: fix testcase method name
We have two test_enable_disable_profile method, the second one should
be called test_set_nickname.

Change-Id: I5ff79218fdafc8c42c8b58cc00be3e56e09d808b
2026-02-09 10:10:08 +01:00
Philipp Maier
15691233e1 tests/pySim-smpp2sim_test: add integration test
At the moment pySim.ota codebase is not covered by any of the
integration tests (we have only normal unittests so far). To
increase the test coverage, let's add an integration test that
sends exchanges an RFM OTA-SMS with a real-world card.

However, there is no tool avaliable that can be used as an SMPP
client for pySim-smpp2sim yet. Let's use smpp_ota_apdu2.py on
laforge/ota to develop a tool that we can use to exchange SMS-TPDUs
that contain remote APDU scripts (RFM/RAM).

Finally let's use the tool we have created as a basis to create
an integration test that exchanges an SMS-TPDU with the RFM
application of a sysmoISIM-SJA5 card. The testcase shall pass
when we get the expected response from the card.

Related: OS#6868
Change-Id: If25e38be004cc1c7aeeb130431831377e78fe28d
2026-02-04 14:05:07 +00:00
Philipp Maier
0a1c5a27d7 esim/http_json_api: add missing apidoc
Change-Id: Ibf9cf06197c9e3203c7a3ea5d77004f0ca41cd3f
2026-02-04 12:45:58 +01:00
Harald Welte
e0a9e73267 http_json_api: Only require Content-Type if response body is non-empty
If there is an empty body returned, such as in the case of the response
to an es9p notification, then it is of course also legal to not set the
content-type header.

This patch fixes an exception when talking to certain SM-DP+ with
es9p_client.py:

DEBUG:pySim.esim.http_json_api:HTTP RSP-STS: [204] hdr: {'X-Admin-Protocol': 'gsma/rsp/v2.5.0', 'Date': 'Wed, 28 Jan 2026 18:26:39 GMT', 'Server': 'REDACTED'}
DEBUG:pySim.esim.http_json_api:HTTP RSP: b''
{'X-Admin-Protocol': 'gsma/rsp/v2.5.0', 'Date': 'Wed, 28 Jan 2026 18:26:39 GMT', 'Server': 'REDACTED'}
<Response [204]>
Traceback (most recent call last):
  File "gprojects/git/pysim/es9p/../contrib/es9p_client.py", line 315, in <module>
    c.do_notification()
    ~~~~~~~~~~~~~~~~~^^
  File "projects/git/pysim/es9p/../contrib/es9p_client.py", line 159, in do_notification
    res = self.peer.call_handleNotification(data)
  File "projects/git/pysim/contrib/pySim/esim/es9p.py", line 174, in call_handleNotification
    return self.handleNotification.call(data)
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
  File "projects/git/pysim/contrib/pySim/esim/http_json_api.py", line 335, in call
    if not response.headers.get('Content-Type').startswith(req_headers['Content-Type']):
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'NoneType' object has no attribute 'startswith'

Change-Id: I99e8f167b7bb869c5ff6d908ba673dac87fef71a
2026-01-31 11:58:32 +01:00
Harald Welte
22c3797a89 es9p_client: MAke install notification code execute at all
The caller specified 'install' but the do_notification() function
compared with 'download' :(

Change-Id: I2d441cfbc1457688eb163301d3d91a1f1fdc7a8c
2026-01-31 10:48:28 +00:00
Harald Welte
4e35e2c357 es9p_client: Fix type conversion in installation result notification
The asn.1 encoder expects bytes-like objects, we cannot simply pass
hex-strings to it without conversion

Change-Id: I83ad047e043dc6b3462b188ce6dd0b2cc0e52e87
2026-01-31 10:48:28 +00:00
Philipp Maier
e62f160775 contrib/csv-to-pgsql: add missing copyright header
Change-Id: Iad8b2c1abb6a80764d05c823fbd03a9eae0ec0ab
2026-01-31 01:32:27 +00:00
Alexander Couzens
1f2db11d31 pySim/card_key_provider: fix typo in keys
Change-Id: Ie76f351ae221da2a0aab65c311fafe8ae6d63663
2026-01-31 01:32:12 +00:00
Vladimir Serbinenko
ae91245582 Print SMSC in pySim-read.py
Change-Id: I17067b68086316d51fd71ba77049874605594e3f
2026-01-31 01:30:32 +00:00
Harald Welte
429b12c8b5 pySim-trace: pySim.apdu_source.stdin_hex
This introduces an "APDU source" for pySim-trace which enables the
decoding of APDUs that are copy+pasted from elsewhere, for example
APDU logs in text form created by proprietary tools, or to decode
personalization scripts or the like.

Change-Id: I5aacf13b7c27cea9efd42f01dacca61068c3aa33
2026-01-31 01:22:48 +00:00
Neels Hofmeyr
ccc1a047ab personalization: set example input values
For all ConfigurableParameter subclasses, provide an example_input.

This may be useful for downstream projects' user interaction, to suggest
a value or prefill an input field, as appropriate.

Related: SYS#6768
Change-Id: I2672fedcbc32cb7a6cb0c233a4a22112bd9aae03
2026-01-30 19:34:13 +00:00
Neels Hofmeyr
db17529136 personalization: set some typical parameter names
These names better match what humans expect to read, for example "PIN1"
instead of "Pin1".

(We still fall back to the __class__.__name__ if a subclass omits a
specific name, see the ConfigurableParameter init.)

Change-Id: I31f390d634e58c384589c50a33ca45d6f86d4e10
2026-01-30 19:34:13 +00:00
Neels Hofmeyr
1c082da0ee personalization: refactor SmspTpScAddr
Refactor SmspTpScAddr to the new ConfigurableParameter implementation
style.

Change-Id: I2600369e195e9f5aed7f4e6ff99ae273ed3ab3bf
2026-01-30 19:34:13 +00:00
Neels Hofmeyr
1e98856105 personalization: refactor SdKey
Refactor SdKey (and subclasses) to the new ConfigurableParameter
implementation style, keeping the same implementation.

But duly note that this implementation does not work!
It correctly patches pe.decoded[], but that gets overridden by
ProfileElementSD._pre_encode().

For a fix, see I07dfc378705eba1318e9e8652796cbde106c6a52.

Change-Id: I427ea851bfa28b2b045e70a19a9e35d361f0d393
2026-01-30 19:34:13 +00:00
Neels Hofmeyr
ae656c66a3 personalization: refactor AlgorithmID, K, Opc
Refactor AlgorithmID, K, Opc to the new ConfigurableParameter
implementation style.

K and Opc use a common abstract BinaryParam.

Note from the future: AlgorithmID so far takes "raw" int values, but
will turn to be an "enum" parameter with predefined meaningful strings
in I71c2ec1b753c66cb577436944634f32792353240

Change-Id: I6296fdcfd5d2ed313c4aade57ff43cc362375848
2026-01-30 19:34:13 +00:00
Neels Hofmeyr
d5b570b01d personalization: refactor Pin, Adm
Refactor Pin1, Pin2, Adm1 and Adm2 to the new ConfigurableParameter
implementation style.

Change-Id: I54aef10b6d4309398d4b779a3740a7d706d68603
2026-01-30 19:34:13 +00:00
Neels Hofmeyr
21641816ea personalization: refactor Puk
Implement abstract DecimalHexParam, and use it to refactor Puk1 and Puk2
to the new ConfigurableParameter implementation style.

DecimalHexParam will also be used for Pin and Adm soon.

Change-Id: I271e6c030c890778ab7af9ab3bc7997e22018f6a
2026-01-30 19:34:13 +00:00
Neels Hofmeyr
742baeab56 personalization: refactor ConfigurableParameter, Iccid, Imsi
Main points/rationales of the refactoring, details below:
1) common validation implementation
2) offer classmethods

The new features are optional, and will be heavily used by batch
personalization patches coming soon.

Implement Iccid and Imsi to use the new way, with a common abstract
DecimalParam implementation.

So far leave the other parameter classes working as they always did, to
follow suit in subsequent commits.

Details:

1) common validation implementation:
There are very common validation steps in the various parameter
implementations. It is more convenient and much more readable to
implement those once and set simple validation parameters per subclass.
So there now is a validate_val() classmethod, which subclasses can use
as-is to apply the validation parameters -- or subclasses can override
their cls.validate_val() for specialized validation.
(Those subclasses that this patch doesn't touch still override the
self.validate() instance method. Hence they still work as before this
patch, but don't use the new common features yet.)

2) offer stateless classmethods:
It is useful for...
- batch processing of multiple profiles (in upcoming patches) and
- user input validation
to be able to have classmethods that do what self.validate() and
self.apply() do, but do not modify any self.* members.
So far the paradigm was to create a class instance to keep state about
the value. This remains available, but in addition we make available the
paradigm of a singleton that is stateless (the classmethods).
Using self.validate() and self.apply() still work the same as before
this patch, i.e. via self.input_value and self.value -- but in addition,
there are now classmethods that don't touch self.* members.

Related: SYS#6768
Change-Id: I6522be4c463e34897ca9bff2309b3706a88b3ce8
2026-01-30 19:34:13 +00:00
Philipp Maier
a4895702d7 transport/init: use PySimLogger to print messages
The module still uses print to output information. Let's replace
those print calls with the more modern PySimLogger method calls.

Change-Id: I2e2ec2b84f3b84dbd8a029ae9bb64b7a96ddbde3
2026-01-28 12:19:54 +01:00
Philipp Maier
2b42877389 pySimLogger: user __name__ of the module when creating a new logger
At the moment we use random identifiers as names when we create a
new logger for pySimLogger. Let's switch to consistently use the
module name here. For the top level modules let's use the program
name so that it will show up in the log instead of __init__.

Change-Id: I49a9beb98845f66247edd42ed548980c97a7151a
2026-01-28 12:19:54 +01:00
Harald Welte
167d6aca36 pySim.esim.saip: Don't try to generate file contents for MF/DF/ADF
only EFs have data content

Change-Id: I02a54a3b2f73a0e9118db87f8b514d1dbf53971f
2026-01-26 21:19:17 +01:00
Harald Welte
d8c45dc07e pySim.esim.saip: Implement optimized file content encoding
Make sure we make use of the fill pattern when encoding file contents:
Only encode the differences to the fill pattern of the file, in order
to reduce the profile download size.

Change-Id: I61e4a5e04beba5c9092979fc546292d5ef3d7aad
2026-01-26 21:19:05 +01:00
Philipp Maier
0a36ba257c pySim/runtime: use log.warning instead of log.warn
The python logger method warn is deprecated since pyton 3.3, let's us
the warning method as suggested.

Change-Id: I3a4c0ca43768198ac6011ebe79050f91c04862e5
2026-01-26 15:21:37 +00:00
Philipp Maier
1f36c9c28a contrib: add utility to receive ES2+handleDownloadProgressInfo calls
We already have a tool to work with the ES2+ API provided by an SMDP+
(es2p_client.py) With this tool we can only make API calls towards
an SMDP+. However, SGP.22 also defines a "reverse direction" ES2+
interface through wich the SMDP+ may make API calls towards the MNO.

At the moment the only possible MNO originated API call is
ES2+handleDownloadProgressInfo. Let's add a simple tool that runs a
HTTP server to receive and log the ES2+handleDownloadProgressInfo
requests.

Related: SYS#7825
Change-Id: I95af30cebae31f7dc682617b1866f4a2dc9b760c
2026-01-22 19:38:01 +01:00
Philipp Maier
e00c0becca esim/http_json_api: extend JSON API with server functionality
At the moment http_json_api only supports the client role. Let's also add
support for the server role.

This patch refactors the existing client code. This in particular means
that the following preperations have to be made:

- To use the existing JsonHttpApiFunction definitions in the client and
  server the scheme has to be symetric. It already is for the most part,
  but it treads the header field differently. So let's just treat the
  header field like any other mandatory field and add it input_params.
  (this does not affect the es9p.py code since in ES9+ the requests have
   no header messages, see also SGP.22, section 6.5.1.1)

- The JsonHttpApiFunction class currently also has the code to perform
  the client requests. Let's seperate that code in a JsonHttpApiClient
  class to which we pass an JsonHttpApiFunction object.

- The code that does the encoding and decoding in the client role has
  lots of conditions the treat the header differently. Let's do the
  decisions about the header in the JsonHttpApiClient. The encoder
  and decoder function should do the generic encoding and decoding
  only. (however, some generic header specific conditions will remain).

The code for the server role logically mirrors the code for the client
role. We add a JsonHttpApiServer class that can be used to create
API endpoints. The API user has to pass in a call_handler through which
the application logic is defined. Above that we add an Es2pApiServer
class in es2p. In this class we implement the logic that runs the
HTTP server and receives the requests. The Es2pApiServer supports all
ES2+ functions defined by GSMA SGP.22. The user may use the provided
Es2pApiServerHandler base class to define the application logic for each
ES2+ function.

Related: SYS#7825
Change-Id: I277aa90fddb5171c4bf6c3436259aa371d30d092
2026-01-22 19:38:01 +01:00
Philipp Maier
148d0a6f90 esim/http_json_api: add missing check
The line actual_sec = func_ex_status.get('statusCodeData', None) suggests
that 'statusCodeData' may be None under normal circumstances. So let's guard
sec.update(actual_sec) so that we won't run into an exception in case
'statusCodeData' is not in func_ex_status.

Related: SYS#7825
Change-Id: I8a1a3cd5e029dba4a3aec1a64702e19b0d694ae2
2026-01-22 18:51:16 +01:00
Harald Welte
51da6263b7 Fix esim.saip.ProfileElementSequence.remove_naas_of_type
This method did not work at all at the moment, likely due to API churn
over time.  This change makes the following exception go away:

Traceback (most recent call last):
  File "projects/git/pysim/contrib/saip-tool.py", line 473, in <module>
    do_remove_naa(pes, opts)
    ~~~~~~~~~~~~~^^^^^^^^^^^
  File "projects/git/pysim/contrib/saip-tool.py", line 203, in do_remove_naa
    pes.remove_naas_of_type(naa)
    ~~~~~~~~~~~~~~~~~~~~~~~^^^^^
  File "projects/git/pysim/contrib/pySim/esim/saip/__init__.py", line 1748, in remove_naas_of_type
    if template in hdr.decoded['eUICC-Mandatory-GFSTEList']:
       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "projects/git/pysim/contrib/pySim/esim/saip/oid.py", line 48, in __eq__
    return (self.intlist == other.intlist)
                            ^^^^^^^^^^^^^
AttributeError: 'str' object has no attribute 'intlist'

A subsequent patch should introduce unit tests to avoid such breakage in
the future.

Change-Id: I88d862d751198c3d1648ab7f11d6e6a8fdbc41c9
2026-01-20 09:50:03 +01:00
Harald Welte
4f1d7d7ac6 saip.validation: Verify unused mandatory services in header
This adds a new check method to the pySim.esim.saip.validation.CheckBasicStructure
class, which ensures that no unused authentication algorithm related mandatory
services are indicated in the ProfileHeader.

So if a profile e.g. states in the header it requires
usim-test-algorithm, but then the actual akaParameter instances do not
actually use that algorithm, it would raise an exception.

Change-Id: Id0e1988ae1936a321d04bc7c3c3a33262c767d30
Related: SYS#7826
2026-01-20 09:50:03 +01:00
Alexander Couzens
8557ec86be saip: ProfileElementSD: call _post_decode() when instantiating with decoded argument
Otherwise self.keys is not generated from the given data and encoding will fail.

Change-Id: I3020f581a908fecc01d5d255ab5991ce1652e3ec
2026-01-17 21:52:38 +00:00
Alexander Couzens
2e7944cc98 saip: calculate the number of records for LF and CY
Some templates (e.g. for 5GS) define files which aren't completely defined.
5GS OPL5G: doesn't have a file size defined in the template,
but a record size.

Change-Id: I5ec1757d6852eb24d3662ec1c3fc88365e90a616
2026-01-14 00:21:33 +00:00
Alexander Couzens
1347d5ffa2 saip: rework file sizes for "half-defined" template files
Define the file size early if possible.
Some templates (e.g. for 5GS) define files which aren't completely defined.
Fixes the parsing for 5GS SUCI_Calc_Info which doesn't have a file size defined.

The saip-tool will other crash when reading a 5G enabled profile:
```
Traceback (most recent call last):
  File "./contrib/saip-tool.py", line 458, in <module>
    pes = ProfileElementSequence.from_der(f.read())
  File "pySim/esim/saip/__init__.py", line 1679, in from_der
    inst.parse_der(der)
    ~~~~~~~~~~~~~~^^^^^
  File "pySim/esim/saip/__init__.py", line 1552, in parse_der
    self.pe_list.append(ProfileElement.from_der(first_tlv, pe_sequence=self))
                        ~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "pySim/esim/saip/__init__.py", line 557, in from_der
    inst._post_decode()
    ~~~~~~~~~~~~~~~~~^^
  File "pySim/esim/saip/__init__.py", line 668, in _post_decode
    self.pe2files()
    ~~~~~~~~~~~~~^^
  File "pySim/esim/saip/__init__.py", line 655, in pe2files
    file = File(k, v, template.files_by_pename.get(k, None))
  File "pySim/esim/saip/__init__.py", line 133, in __init__
    self.from_tuples(l)
    ~~~~~~~~~~~~~~~~^^^
  File "pySim/esim/saip/__init__.py", line 358, in from_tuples
    self._body = self.file_content_from_tuples(l)
                 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^
  File "pySim/esim/saip/__init__.py", line 393, in file_content_from_tuples
    stream.write(self.template.expand_default_value_pattern(self.file_size))
                 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^
  File "pySim/esim/saip/templates.py", line 123, in expand_default_value_pattern
    raise ValueError("%s does not have a default length" % self)
ValueError: FileTemplate(EF.SUCI_Calc_Info) does not have a default length
```

Change-Id: I7c4a0914aef1049a416e6b091f23daab39a1dd9c
2026-01-14 00:21:33 +00:00
Philipp Maier
fddab8639f card_key_provider: add PostgreSQL support
The Card Key Provider currently only has support for CSV files
as input. Unfortunately using CSV files does not scale very well
when the card inventory is very large and continously updated.
In this case a centralized storage in the form of a database
is the more suitable approach.

This patch adds PostgreSQL support next to the existing CSV
file support. It also adds an importer tool to import existing
CSV files into the database.

Change-Id: Icba625c02a60d7e1f519b506a46bda5ded0537d3
Related: SYS#7725
2026-01-12 10:57:27 +01:00
Philipp Maier
eb7c5d85d0 runtime/cosmetic: add line break
The other source files have a line break between the character encoding
qualifier line and the python comment. Let's add a line break here
as well to maintain consistency.

Change-Id: Ied6b77eede748f1ddf6fde17c9b434fa4dd1114a
2026-01-06 15:03:53 +01:00
Philipp Maier
eda6182edd transport/init/cosmetic: move copryight header to the top
The copyright header seems to be misplaced, let's move it to the top.

Change-Id: I8358cca3bc9adb5a186a8b38a3bd90d7aec60d5c
2026-01-06 15:00:49 +01:00
Alexander Couzens
725ffffda1 RFC: saip: templates: fix naming of EF.SUPI_NAI
Fixes parsing of a 2.3 UICC profile.
This might be the wrong end as the spec says this is
NSI, but somehow it's working

Change-Id: I3cde1093156db274458d76e2c1c2e304d55a8466
2026-01-07 12:28:25 +00:00
Alexander Couzens
777d005350 saip: templates: IsimOptional: add missing pe_name=ef-pcscf
The file EF.P-CSCF is named ef-pcscf in the asn1 (tested with version 2.3)

Change-Id: I0cfba8f4e97fd6e2d8e21edf0439692b58a78ded
2026-01-07 12:28:25 +00:00
Neels Hofmeyr
6e9625213a fix typo in doc TuakNumberOfKeccak
Change-Id: Ie6f2260d5632dea7409cffd3afa7c8d0b1986a7c
2026-01-07 00:00:21 +01:00
Philipp Maier
4c8a9478c2 cosmetic: fix company name in copyright header.
The correct abbreviated version of the company name is
"sysmocom - s.f.m.c. GmbH", i.e. lowercase and with dash.

Change-Id: Id768d2f4b78162ff83320a800e4e66f1bd324d6d
2026-01-06 21:41:23 +00:00
Philipp Maier
dfe4d9c8ac contrib: add a tool to parse the SIMA response from an eUICC
When an eUICC performs a profile installation it returns a (concatenated)
series of ASN.1 encoded strings as "simaResponse". In case the profile
installation fails for some reason the simaResponse contains diagnostic
information to diagnose why the profile installation failed.

Unfortunately there are currently no practical tools available to decode
and display the information in the simaResponse. Let's add a tool for that.

Related SYS#7617

Change-Id: Ida4c3c5446653b283a3869c0c387f328ae51e55e
2026-01-06 21:41:23 +00:00
Philipp Maier
8e048820d4 pySim-shell: renovate version command
In case pySim-shell is used directly from the git repository (not
installed via a package manager), the version command fails with an
exception because pkg_resources.get_distribution('pySim') fails.

Let's renovate the version command and migrate from pkg_resources to
importlib.resources. There are many users and developers out there who
retrieve pySim-shell directly from the git repository and not via pip3.
To accommodate for that, let's check if pySim-shell.py is located in a
git repository and if so, let's display the HEAD commit hash instead.

Since the version of the currently installed pyosmocom version also
plays a critical role, let's display the pyosmocom version as well.

Related: OS#6830
Change-Id: I2b9038f88cfcaa07894a2f09c7f5ad8a5474083d
2026-01-06 21:11:16 +00:00
Philipp Maier
c2ace3d8cf unittests/test_utils: add unittests for enc_imsi and dec_imsi
So far we seem to have no unittests for enc_imsi and dec_imsi.

Change-Id: Iae55485c5ec7763aa4aaa25fd1910b854adaab60
2026-01-06 21:10:40 +00:00
Harald Welte
097d565310 esim.saip: Better docstring about FsNode class
Change-Id: Id9d196e8d9b1d1b892ec50100b170d72d2c3910b
2026-01-06 21:10:29 +00:00
Harald Welte
a8ae89a041 pySim.esim.saip.ProfileElementSequence: Update type annotations
The type annotations didn't reflect reality in two cases.

Change-Id: Ib99c00a38bf009c63180b4a593d6cc796ff282d3
2026-01-06 21:10:19 +00:00
Philipp Maier
d764659a30 pySim-shell: do not show user home path in help text
At the moment, the help text for the --csv option shows the path to
the users home. This is due to the default value, which is dynamically
generated. Let's use a static string with "~/" and resolve the full
path later when we need it.

Related: SYS#7725
Change-Id: Ied8b1e553de8f5370369c4485a2360906c874ed2
2026-01-05 14:54:26 +00:00
Philipp Maier
3ca25219bc pySim-shell/cosmetic: remove unnecessary brackets
Change-Id: I6929aa4fa189414217c05a4ef5180d4ed44eb3a4
2026-01-05 14:42:49 +00:00
Harald Welte
1da34c1a4f Fix more odd-length digit sequences via PaddedBcdAdapter
There are more files where trailing digits are indicated using 'f' and
should be stripped during decode, including EF.MSISDN and EF.VGCS

This is not just a presentation issue, but actually rendered wrong data
before, see the modified test output where our "read_record_uicc.ok"
file contained "bcd_len: 7" but then only 6 BCD digits due to this bug.

Change-Id: I4571482da924a3d645caa297108279d182448d21
2025-12-23 20:57:03 +01:00
Harald Welte
381519556c ts_31_102.EF_ECC: Use PaddedBcdAdapter to skip trailing 'f'
The emergency numbers from the example are 911 / 913, and not 911f / 311f

Change-Id: Ibfe1e23431aa803b936dd8529e0542e93d9df0b9
2025-12-23 20:57:03 +01:00
Harald Welte
0fe432fec9 pySim.esim.saip.personalization: Support for EF.SMSP personalization
It's a not-too-uncommon requirement to modify the SMSC address stored in
EF.SMSP.  This adds a ConfigurableParameter for this purpose.

Change-Id: I6b0776c2e753e0a6d158a8cf65cb030977782ec2
2025-12-23 20:57:03 +01:00
Harald Welte
c6fd1d314a esim.saip.FsProfileElement: Add file2pe() for single file conversion
We've had files2pe() for re-encoding all of the files, but let's add
a specific one for re-encoding only one of the files (such as commonly
needed during personalization)

Change-Id: I7b7f61aae6b7df6946dadf2f78fddf92995603ec
2025-12-23 20:57:01 +01:00
Harald Welte
88aff4c577 pySim.ts_51_011.EF_SMSP: Properly handle odd-length ScAddr / TpAddr
As the input phone number ("address") might be of an odd length of
digits, let's use PaddedBcdAdapter to fix two problems:

1) strip any potential trailing f in decoding
2) fix truncation of last digit during encoding

Change-Id: I1e9865e172bc29b8a31c281106d903934e81c686
Depends: pyosmocom Ib5afb5ab5c2bc9b519dc92818fc6974f7eecba16 (0.0.12
2025-12-23 16:21:22 +00:00
Harald Welte
5fe76bb680 pySim/ts_51_011: Properly re-compute ScAddr length
EF.SMSP contains up to two addresses: Both are stored in a fixed-length
field of 12 octets.  However, the actually used size depends on the
number of digits in the respective number.  Let's compute that length
field properly

Change-Id: Idef54a3545d1a5367a1efa2f0a6f7f0c1f860105
2025-12-23 16:21:22 +00:00
Harald Welte
c058c6a34d ts_51_011: Improve testing of EF_SMSP
* add another set of test data (from a real-world SIM card)
* switch from test_decode to test_de_encode as our encoder now works due
  to previous commits.

Change-Id: I8d16e195641bb59b2c26072008f88434692c0cab
2025-12-23 16:21:22 +00:00
Philipp Maier
3d42106ad9 pysim/log: also accept ANSI strings to specify the log message colors
the PySimLogger class currently only accepts cmd2 color enum values.
This is what we need for pySim-shell.py. However, in case we want to
use the PySimLogger in stand-alone programs that do not use cmd2, this
is a bit bulky. Let's add some flexibility to PySimLogger, so that we
can specify the colors as raw ANSI strings as well.

Change-Id: I93543e19649064043ae8323f82ecd8c423d1d921
Related: SYS#7725
2025-12-19 16:12:31 +01:00
Harald Welte
9a23eab163 unittests/test_files: Pass to-be-encoded length to encoder functions
Some of the encoders can only generate valid output if they are told
the expected output size.  This is due to variable-length fields that
depend on the size of the total record (or file).  Let's always pass
the expected length to the encoder methods.

Change-Id: I88f957e49b0c88a121a266d3582e79822fa0e214
2025-12-18 20:38:59 +01:00
Harald Welte
82b57403c7 unittest/test_files.TransparentEF_Test: Actually test encoder
In the test_encode_file() method, we should actually test the encoder,
and not the decoder.  I suppose this was a copy+paste mistake at some
point?  In the LinearFixedEF_Test.test_encoder_record we were already
testing the encoder. Just TransparentEF_Test got it wrong...

Change-Id: Id23305a78ab9acd2e006f2b26b72408795844d23
2025-12-18 20:38:59 +01:00
Harald Welte
a62fb2b987 ts_51_011/EF.SMSP: Fix parsing of parameter_indicators
There's a 3-bit RFU field that (unlike everything else in USIM/UICC)
considers '1' to be the default.  Let's make sure we get that right
during encode.

Change-Id: Ibe24a07f5f73d875d2077fa55471dbfc4e90da23
2025-12-18 20:38:59 +01:00
Harald Welte
111f9da4f5 pyshark_gsmtap: Adjust display filter for some wireshark versions
On my debian unstable system with wireshark 4.6.2-3, the pyshark_gsmtap
APDU source misses to report any ATRs, as those are not part of what's
reported with the 'gsm_sim' display filter.  This is due to
wireshark.git commit bcd82e2370d18e20983b378d494964d89c191cef first part
of the 4.6.0 release, which splits the ATR dissection into a separate
sub-dissector.

We cannot use the seemingly logical 'gsmtap.type == 4' instead, as old
wireshark simply bypasses any output for the gsmtap header if the SIM
sub-dissector is used.

Hence, 'gsm_sim || iso7816.atr' is something compatible with older and
newer wireshark versions.

Change-Id: I53c1c8ed58a82c37cd4be4af3890af21da839e86
2025-12-18 20:35:49 +01:00
Harald Welte
ddbf91fc4a pySim.esim.saip.personalization: Support Milenage customization
Milenage offers the capability for operators to modify the r1-r5
rotation constants as well as the c1-c5 xor-ing constants; let's
add ConfigurableParameters for that.

Change-Id: I397df6c0c708a8061e4adc0fde03a3f746bcb5b6
Related: SYS#7787
2025-12-18 14:42:52 +01:00
Harald Welte
45bffb53f9 pySim.ts_51_011.EF_SMSP: Also permit UCS2 for the alpha_id
TS 51.011 Section 10.5.6 refers to clause 10.5.1 (EF.ADN),
and the latter permits UCS2 in addition to 7-bit GSM alphabet.

Change-Id: If10b3d6d8b34ece02dc0350ca9ea9c3f8fbf3c9e
2025-12-16 16:31:14 +01:00
Harald Welte
cc15b2b4c3 ts_51_011.EF_SMSP: Use integer division during encode
Otherwise we might compute float values and fail encoding like this:

> construct.core.FormatFieldError: Error in path (building) -> tp_vp_minutes
> struct '>B' error during building, given value 169.0

Change-Id: I989669434c7ddee9595ee81a0822f9966907a844
2025-12-16 16:31:12 +01:00
Harald Welte
11dfad88e6 pySim.esim.saip: Fix compatibility with pytohn < 3.11
In python up to 3.10, the byteorder argument of the int.to_bytes()
method was mandatory, even if the length of the target byte sequence
is 1 and there factually is no byteorder.

https://docs.python.org/3.10/library/stdtypes.html#int.to_bytes
vs
https://docs.python.org/3.11/library/stdtypes.html#int.to_bytes

See also: https://discourse.osmocom.org/t/assistance-required-with-saip-pysim-tool-error-while-adding-applets-to-exiting-upp-der/2413/2

Change-Id: I8ba29d42c8d7bf0a36772cc3370eff1f6afa879f
2025-12-14 13:58:34 +01:00
Harald Welte
572a81f2af pySim.runtime: Fix file selection by upper case hex FID
When trying to remove a file (e.g. DF.5G_ProSe, 5FF0),
there seems to be a case sensitive check when checking for the dict:
pySim/runtime.py: get_file_for_filename():

478          def get_file_for_filename(self, name: str):
479              """Get the related CardFile object for a specified filename."""
480              sels = self.selected_file.get_selectables()
481              return sels[name]

The dict sels contains 5ff0, but not 5FF0.
The type of argument name is str. So a case sensitive check will be used.

Change-Id: Idd0db1f4bbd3ee9eec20f5fd0f4371c2882950cd
Closes: OS#6898
2025-12-10 13:34:27 +00:00
Neels Hofmeyr
ff4f2491b8 fix downstream error: ImportError: cannot import name 'style' from 'cmd2'
cmd2 version 3.0 was released, with significant API changes. Limit the
dependency to below 3.0, as already reflected in requirements.txt.

Seeing but not changing the discrepancy in minimum version:
requirements.txt has >2.6.2 while setup.py has >= 1.5.0.

Related: SYS#7775 SYS#7777
Change-Id: I5186f242dbc1b770e3ab8cdca7f27d2a1029fff6
2025-12-10 04:06:43 +01:00
Harald Welte
05fd870d1b contrib/saip-tool: Use repr() on security domain keys
Let's not reinvent the wheel of printing such data structures and use
the repr method provided by the respective class instead.  This also
adds the missing key_usage_qualifier information to the print-out,
as well as the mac_len of the key components.

Change-Id: Iaead4a02f07130fd00bcecc43e1c843f1c221e63
2025-12-09 16:23:49 +00:00
Harald Welte
c07ecbae52 pySim.esim.saip: Hex representation of SecurityDomainKey
Let's print the key_usage_qualifier in hexadecimal notation (more compact)

Change-Id: Ic9a92d53d73378eafca1760dd8351215bce1157a
2025-12-09 16:23:49 +00:00
Alexander Couzens
e20f9e6cdf ts_102_221: EF.ARR: fix read_arr_record
`read_arr_record 1` failed with an AttributeError exception
because RECORD_NR must be all caps.

Change-Id: If44f9f2271293d3063f6c527e5a68dcfaeb5942e
2025-12-04 15:32:16 +01:00
90 changed files with 8403 additions and 513 deletions

3
.gitignore vendored
View File

@@ -1,8 +1,9 @@
*.pyc
.*.swp
.*.sw?
/docs/_*
/docs/generated
/docs/filesystem.rst
/.cache
/.local
/build

112
contrib/analyze_simaResponse.py Executable file
View File

@@ -0,0 +1,112 @@
#!/usr/bin/env python3
# A tool to analyze the eUICC simaResponse (series of EUICCResponse)
#
# (C) 2025 by sysmocom - s.f.m.c. GmbH
# All Rights Reserved
#
# 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 <http://www.gnu.org/licenses/>.
#
import argparse
from osmocom.utils import h2b, b2h
from osmocom.tlv import bertlv_parse_one, bertlv_encode_tag, bertlv_encode_len
from pySim.esim.saip import *
parser = argparse.ArgumentParser(description="""Utility program to analyze the contents of an eUICC simaResponse.""")
parser.add_argument('SIMA_RESPONSE', help='Hexstring containing the simaResponse as received from the eUICC')
def split_sima_response(sima_response):
"""split an eUICC simaResponse field into a list of EUICCResponse fields"""
remainder = sima_response
result = []
while len(remainder):
tdict, l, v, next_remainder = bertlv_parse_one(remainder)
rawtag = bertlv_encode_tag(tdict)
rawlen = bertlv_encode_len(l)
result = result + [remainder[0:len(rawtag) + len(rawlen) + l]]
remainder = next_remainder
return result
def analyze_status(status):
"""
Convert a status code (integer) into a human readable string
(see eUICC Profile Package: Interoperable Format Technical Specification, section 8.11)
"""
# SIMA status codes
string_values = {0 : 'ok',
1 : 'pe-not-supported',
2 : 'memory-failure',
3 : 'bad-values',
4 : 'not-enough-memory',
5 : 'invalid-request-format',
6 : 'invalid-parameter',
7 : 'runtime-not-supported',
8 : 'lib-not-supported',
9 : 'template-not-supported ',
10 : 'feature-not-supported',
11 : 'pin-code-missing',
31 : 'unsupported-profile-version'}
string_value = string_values.get(status, None)
if string_value is not None:
return "%d = %s (SIMA status code)" % (status, string_value)
# ISO 7816 status words
if status >= 24576 and status <= 28671:
return "%d = %04x (ISO7816 status word)" % (status, status)
elif status >= 36864 and status <= 40959:
return "%d = %04x (ISO7816 status word)" % (status, status)
# Proprietary status codes
elif status >= 40960 and status <= 65535:
return "%d = %04x (proprietary)" % (status, status)
# Unknown status codes
return "%d (unknown, proprietary?)" % status
def analyze_euicc_response(euicc_response):
"""Analyze and display the contents of an EUICCResponse"""
print(" EUICCResponse: %s" % b2h(euicc_response))
euicc_response_decoded = asn1.decode('EUICCResponse', euicc_response)
pe_status = euicc_response_decoded.get('peStatus')
print(" peStatus:")
for s in pe_status:
print(" status: %s" % analyze_status(s.get('status')))
print(" identification: %s" % str(s.get('identification', None)))
print(" additional-information: %s" % str(s.get('additional-information', None)))
print(" offset: %s" % str(s.get('offset', None)))
if euicc_response_decoded.get('profileInstallationAborted', False) is None:
# This type is defined as profileInstallationAborted NULL OPTIONAL, so when it is present it
# will have the value None, otherwise it is simply not present.
print(" profileInstallationAborted: True")
else:
print(" profileInstallationAborted: False")
status_message = euicc_response_decoded.get('statusMessage', None)
print(" statusMessage: %s" % str(status_message))
if __name__ == '__main__':
opts = parser.parse_args()
sima_response = h2b(opts.SIMA_RESPONSE);
print("simaResponse: %s" % b2h(sima_response))
euicc_response_list = split_sima_response(sima_response)
for euicc_response in euicc_response_list:
analyze_euicc_response(euicc_response)

301
contrib/csv-to-pgsql.py Executable file
View File

@@ -0,0 +1,301 @@
#!/usr/bin/env python3
# (C) 2025 by sysmocom - s.f.m.c. GmbH
# All Rights Reserved
#
# Author: Philipp Maier
#
# 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 <http://www.gnu.org/licenses/>.
import argparse
import logging
import csv
import sys
import os
import yaml
import psycopg2
from psycopg2.sql import Identifier, SQL
from pathlib import Path
from pySim.log import PySimLogger
from packaging import version
log = PySimLogger.get(Path(__file__).stem)
class CardKeyDatabase:
def __init__(self, config_filename: str, table_name: str, create_table: bool = False, admin: bool = False):
"""
Initialize database connection and set the table which shall be used as storage for the card key data.
In case the specified table does not exist yet it can be created using the create_table_type parameter.
New tables are always minimal tables which follow a pre-defined table scheme. The user may extend the table
with additional columns using the add_cols() later.
Args:
tablename : name of the database table to create.
create_table_type : type of the table to create ('UICC' or 'EUICC')
"""
def user_from_config_file(config, role: str) -> tuple[str, str]:
db_users = config.get('db_users')
user = db_users.get(role)
if user is None:
raise ValueError("user for role '%s' not set up in config file." % role)
return user.get('name'), user.get('pass')
self.table = table_name.lower()
self.cols = None
# Depending on the table type, the table name must contain either the substring "uicc_keys" or "euicc_keys".
# This convention will allow us to deduct the table type from the table name.
if "euicc_keys" not in table_name and "uicc_keys" not in table_name:
raise ValueError("Table name (%s) should contain the substring \"uicc_keys\" or \"euicc_keys\"" % table_name)
# Read config file
log.info("Using config file: %s", config_filename)
with open(config_filename, "r") as cfg:
config = yaml.load(cfg, Loader=yaml.FullLoader)
host = config.get('host')
log.info("Database host: %s", host)
db_name = config.get('db_name')
log.info("Database name: %s", db_name)
table_names = config.get('table_names')
username_admin, password_admin = user_from_config_file(config, 'admin')
username_importer, password_importer = user_from_config_file(config, 'importer')
username_reader, _ = user_from_config_file(config, 'reader')
# Switch between admin and importer user
if admin:
username, password = username_admin, password_admin
else:
username, password = username_importer, password_importer
# Create database connection
log.info("Database user: %s", username)
self.conn = psycopg2.connect(dbname=db_name, user=username, password=password, host=host)
self.cur = self.conn.cursor()
# In the context of this tool it is not relevant if the table name is present in the config file. However,
# pySim-shell.py will require the table name to be configured properly to access the database table.
if self.table not in table_names:
log.warning("Specified table name (%s) is not yet present in config file (required for access from pySim-shell.py)",
self.table)
# Create a new minimal database table of the specified table type.
if create_table:
if not admin:
raise ValueError("creation of new table refused, use option --admin and try again.")
if "euicc_keys" in self.table:
self.__create_table(username_reader, username_importer, ['EID'])
elif "uicc_keys" in self.table:
self.__create_table(username_reader, username_importer, ['ICCID', 'IMSI'])
# Ensure a table with the specified name exists
log.info("Database table: %s", self.table)
if self.get_cols() == []:
raise ValueError("Table name (%s) does not exist yet" % self.table)
log.info("Database table columns: %s", str(self.get_cols()))
def __create_table(self, user_reader:str, user_importer:str, cols:list[str]):
"""
Initialize a new table. New tables are always minimal tables with one primary key and additional index columns.
Non index-columns may be added later using method _update_cols().
"""
# Create table columns with primary key
query = SQL("CREATE TABLE {} ({} VARCHAR PRIMARY KEY").format(Identifier(self.table),
Identifier(cols[0].lower()))
for c in cols[1:]:
query += SQL(", {} VARCHAR").format(Identifier(c.lower()))
query += SQL(");")
self.cur.execute(query)
# Create indexes for all other columns
for c in cols[1:]:
self.cur.execute(query = SQL("CREATE INDEX {} ON {}({});").format(Identifier(c.lower()),
Identifier(self.table),
Identifier(c.lower())))
# Set permissions
self.cur.execute(SQL("GRANT INSERT ON {} TO {};").format(Identifier(self.table),
Identifier(user_importer)))
self.cur.execute(SQL("GRANT SELECT ON {} TO {};").format(Identifier(self.table),
Identifier(user_reader)))
log.info("New database table created: %s", self.table)
def get_cols(self) -> list[str]:
"""
Get a list of all columns available in the current table scheme.
Returns:
list with column names (in uppercase) of the database table
"""
# Return cached col list if present
if self.cols:
return self.cols
# Request a list of current cols from the database
self.cur.execute("SELECT column_name FROM information_schema.columns where table_name = %s;", (self.table,))
cols_result = self.cur.fetchall()
cols = []
for c in cols_result:
cols.append(c[0].upper())
self.cols = cols
return cols
def get_missing_cols(self, cols_expected:list[str]) -> list[str]:
"""
Check if the current table scheme lacks any of the given expected columns.
Returns:
list with the missing columns.
"""
cols_present = self.get_cols()
return list(set(cols_expected) - set(cols_present))
def add_cols(self, cols:list[str]):
"""
Update the current table scheme with additional columns. In case the updated columns are already exist, the
table schema is not changed.
Args:
table : name of the database table to alter
cols : list with updated colum names to add
"""
cols_missing = self.get_missing_cols(cols)
# Depending on the table type (see constructor), we either have a primary key 'ICCID' (for UICC data), or 'EID'
# (for eUICC data). Both table formats different types of data and have rather differen columns also. Let's
# prevent the excidentally mixing of both types.
if 'ICCID' in cols_missing:
raise ValueError("Table %s stores eUCCC key material, refusing to add UICC specific column 'ICCID'" % self.table)
if 'EID' in cols_missing:
raise ValueError("Table %s stores UCCC key material, refusing to add eUICC specific column 'EID'" % self.table)
# Add the missing columns to the table
self.cols = None
for c in cols_missing:
self.cur.execute(query = SQL("ALTER TABLE {} ADD {} VARCHAR;").format(Identifier(self.table),
Identifier(c.lower())))
def insert_row(self, row:dict[str, str]):
"""
Insert a new row into the database table.
Args:
row : dictionary with the colum names and their designated values
"""
# Check if the row is compatible with the current table scheme
cols_expected = list(row.keys())
cols_missing = self.get_missing_cols(cols_expected)
if cols_missing != []:
raise ValueError("table %s has incompatible format, the row %s contains unknown cols %s" %
(self.table, str(row), str(cols_missing)))
# Insert row into datbase table
row_keys = list(row.keys())
row_values = list(row.values())
query = SQL("INSERT INTO {} ").format(Identifier(self.table))
query += SQL("({} ").format(Identifier(row_keys[0].lower()))
for k in row_keys[1:]:
query += SQL(", {}").format(Identifier(k.lower()))
query += SQL(") VALUES (%s")
for v in row_values[1:]:
query += SQL(", %s")
query += SQL(");")
self.cur.execute(query, row_values)
def commit(self):
self.conn.commit()
log.info("Changes to table %s committed!", self.table)
def open_csv(opts: argparse.Namespace):
log.info("CSV file: %s", opts.csv)
csv_file = open(opts.csv, 'r')
cr = csv.DictReader(csv_file)
if not cr:
raise RuntimeError("could not open DictReader for CSV-File '%s'" % opts.csv)
cr.fieldnames = [field.upper() for field in cr.fieldnames]
log.info("CSV file columns: %s", str(cr.fieldnames))
return cr
def open_db(cr: csv.DictReader, opts: argparse.Namespace) -> CardKeyDatabase:
try:
db = CardKeyDatabase(os.path.expanduser(opts.pgsql), opts.table_name, opts.create_table, opts.admin)
# Check CSV format against table schema, add missing columns
cols_missing = db.get_missing_cols(cr.fieldnames)
if cols_missing != [] and (opts.update_columns or opts.create_table):
log.info("Adding missing columns: %s", str(cols_missing))
db.add_cols(cols_missing)
cols_missing = db.get_missing_cols(cr.fieldnames)
# Make sure the table schema has no missing columns
if cols_missing != []:
log.error("Database table lacks CSV file columns: %s -- import aborted!", cols_missing)
sys.exit(2)
except Exception as e:
log.error(str(e).strip())
log.error("Database initialization aborted due to error!")
sys.exit(2)
return db
def import_from_csv(db: CardKeyDatabase, cr: csv.DictReader):
count = 0
for row in cr:
try:
db.insert_row(row)
count+=1
if count % 100 == 0:
log.info("CSV file import in progress, %d rows imported...", count)
except Exception as e:
log.error(str(e).strip())
log.error("CSV file import aborted due to error, no datasets committed!")
sys.exit(2)
log.info("CSV file import done, %d rows imported", count)
if __name__ == '__main__':
option_parser = argparse.ArgumentParser(description='CSV importer for pySim-shell\'s PostgreSQL Card Key Provider',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
option_parser.add_argument("--verbose", help="Enable verbose logging", action='store_true', default=False)
option_parser.add_argument('--pgsql', metavar='FILE',
default="~/.osmocom/pysim/card_data_pgsql.cfg",
help='Read card data from PostgreSQL database (config file)')
option_parser.add_argument('--csv', metavar='FILE', help='input CSV file with card data', required=True)
option_parser.add_argument("--table-name", help="name of the card key table", type=str, required=True)
option_parser.add_argument("--update-columns", help="add missing table columns", action='store_true', default=False)
option_parser.add_argument("--create-table", action='store_true', help="create new card key table", default=False)
option_parser.add_argument("--admin", action='store_true', help="perform action as admin", default=False)
opts = option_parser.parse_args()
PySimLogger.setup(print, {logging.WARN: "\033[33m"}, opts.verbose)
# Open CSV file
cr = open_csv(opts)
# Open database, create initial table, update column scheme
db = open_db(cr, opts)
# Progress with import
if not opts.admin:
import_from_csv(db, cr)
# Commit changes to the database
db.commit()

100
contrib/es2p_server.py Executable file
View File

@@ -0,0 +1,100 @@
#!/usr/bin/env python3
# (C) 2026 by sysmocom - s.f.m.c. GmbH
# All Rights Reserved
#
# Author: Philipp Maier
#
# 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 sys
import argparse
import logging
import json
import asn1tools
import asn1tools.codecs.ber
import asn1tools.codecs.der
import pySim.esim.rsp as rsp
import pySim.esim.saip as saip
from pySim.esim.es2p import param, Es2pApiServerMno, Es2pApiServerHandlerMno
from osmocom.utils import b2h
from datetime import datetime
from analyze_simaResponse import split_sima_response
from pathlib import Path
logger = logging.getLogger(Path(__file__).stem)
parser = argparse.ArgumentParser(description="""
Utility to receive and log requests against the ES2+ API of an SM-DP+ according to GSMA SGP.22.""")
parser.add_argument("--host", help="Host/IP to bind HTTP(S) to", default="localhost")
parser.add_argument("--port", help="TCP port to bind HTTP(S) to", default=443, type=int)
parser.add_argument('--server-cert', help='X.509 server certificate used to provide the ES2+ HTTPs service')
parser.add_argument('--client-ca-cert', help='X.509 CA certificates to authenticate the requesting client(s)')
parser.add_argument("-v", "--verbose", help="enable debug output", action='store_true', default=False)
def decode_sima_response(sima_response):
decoded = []
euicc_response_list = split_sima_response(sima_response)
for euicc_response in euicc_response_list:
decoded.append(saip.asn1.decode('EUICCResponse', euicc_response))
return decoded
def decode_result_data(result_data):
return rsp.asn1.decode('PendingNotification', result_data)
def decode(data, path="/"):
if data is None:
return 'none'
elif type(data) is datetime:
return data.isoformat()
elif type(data) is tuple:
return {str(data[0]) : decode(data[1], path + str(data[0]) + "/")}
elif type(data) is list:
new_data = []
for item in data:
new_data.append(decode(item, path))
return new_data
elif type(data) is bytes:
return b2h(data)
elif type(data) is dict:
new_data = {}
for key, item in data.items():
new_key = str(key)
if path == '/' and new_key == 'resultData':
new_item = decode_result_data(item)
elif (path == '/resultData/profileInstallationResult/profileInstallationResultData/finalResult/successResult/' \
or path == '/resultData/profileInstallationResult/profileInstallationResultData/finalResult/errorResult/') \
and new_key == 'simaResponse':
new_item = decode_sima_response(item)
else:
new_item = item
new_data[new_key] = decode(new_item, path + new_key + "/")
return new_data
else:
return data
class Es2pApiServerHandlerForLogging(Es2pApiServerHandlerMno):
def call_handleDownloadProgressInfo(self, data: dict) -> (dict, str):
logging.info("ES2+:handleDownloadProgressInfo: %s" % json.dumps(decode(data)))
return {}, None
if __name__ == "__main__":
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.WARNING,
format='%(asctime)s %(levelname)s %(message)s',
datefmt='%Y-%m-%d %H:%M:%S')
Es2pApiServerMno(args.port, args.host, Es2pApiServerHandlerForLogging(), args.server_cert, args.client_ca_cert)

View File

@@ -126,14 +126,14 @@ class Es9pClient:
if self.opts.iccid:
ntf_metadata['iccid'] = h2b(swap_nibbles(self.opts.iccid))
if self.opts.operation == 'download':
if self.opts.operation == 'install':
pird = {
'transactionId': self.opts.transaction_id,
'transactionId': h2b(self.opts.transaction_id),
'notificationMetadata': ntf_metadata,
'smdpOid': self.opts.smdpp_oid,
'finalResult': ('successResult', {
'aid': self.opts.isdp_aid,
'simaResponse': self.opts.sima_response,
'aid': h2b(self.opts.isdp_aid),
'simaResponse': h2b(self.opts.sima_response),
}),
}
pird_bin = rsp.asn1.encode('ProfileInstallationResultData', pird)

View File

@@ -42,6 +42,9 @@ case "$JOB_TYPE" in
# Run pySim-shell integration tests (requires physical cards)
python3 -m unittest discover -v -s ./tests/pySim-shell_test/
# Run pySim-smpp2sim test
tests/pySim-smpp2sim_test/pySim-smpp2sim_test.sh
;;
"distcheck")
virtualenv -p python3 venv --system-site-packages

View File

@@ -107,7 +107,7 @@ parser_esrv.add_argument('--output-file', required=True, help='Output file name'
parser_esrv.add_argument('--add-flag', default=[], choices=esrv_flag_choices, action='append', help='Add flag to mandatory services list')
parser_esrv.add_argument('--remove-flag', default=[], choices=esrv_flag_choices, action='append', help='Remove flag from mandatory services list')
parser_info = subparsers.add_parser('tree', help='Display the filesystem tree')
parser_tree = subparsers.add_parser('tree', help='Display the filesystem tree')
def write_pes(pes: ProfileElementSequence, output_file:str):
"""write the PE sequence to a file"""
@@ -329,7 +329,7 @@ def do_info(pes: ProfileElementSequence, opts):
print("Security domain Instance AID: %s" % b2h(sd.decoded['instance']['instanceAID']))
# FIXME: 'applicationSpecificParametersC9' parsing to figure out enabled SCP
for key in sd.keys:
print("\tKVN=0x%02x, KID=0x%02x, %s" % (key.key_version_number, key.key_identifier, key.key_components))
print("\t%s" % repr(key))
# RFM
print()

240
contrib/smpp-ota-tool.py Executable file
View File

@@ -0,0 +1,240 @@
#!/usr/bin/env python3
# (C) 2026 by sysmocom - s.f.m.c. GmbH
# All Rights Reserved
#
# Author: Harald Welte, Philipp Maier
#
# 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 <http://www.gnu.org/licenses/>.
import argparse
import logging
import smpplib.gsm
import smpplib.client
import smpplib.consts
import time
from pySim.ota import OtaKeyset, OtaDialectSms, OtaAlgoCrypt, OtaAlgoAuth, CNTR_REQ, RC_CC_DS, POR_REQ
from pySim.utils import b2h, h2b, is_hexstr
from pathlib import Path
logger = logging.getLogger(Path(__file__).stem)
option_parser = argparse.ArgumentParser(description='Tool to send OTA SMS RFM/RAM messages via SMPP',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
option_parser.add_argument("--host", help="Host/IP of the SMPP server", default="localhost")
option_parser.add_argument("--port", help="TCP port of the SMPP server", default=2775, type=int)
option_parser.add_argument("--system-id", help="System ID to use to bind to the SMPP server", default="test")
option_parser.add_argument("--password", help="Password to use to bind to the SMPP server", default="test")
option_parser.add_argument("--verbose", help="Enable verbose logging", action='store_true', default=False)
algo_crypt_choices = []
algo_crypt_classes = OtaAlgoCrypt.__subclasses__()
for cls in algo_crypt_classes:
algo_crypt_choices.append(cls.enum_name)
option_parser.add_argument("--algo-crypt", choices=algo_crypt_choices, default='triple_des_cbc2',
help="OTA crypt algorithm")
algo_auth_choices = []
algo_auth_classes = OtaAlgoAuth.__subclasses__()
for cls in algo_auth_classes:
algo_auth_choices.append(cls.enum_name)
option_parser.add_argument("--algo-auth", choices=algo_auth_choices, default='triple_des_cbc2',
help="OTA auth algorithm")
option_parser.add_argument('--kic', required=True, type=is_hexstr, help='OTA key (KIC)')
option_parser.add_argument('--kic-idx', default=1, type=int, help='OTA key index (KIC)')
option_parser.add_argument('--kid', required=True, type=is_hexstr, help='OTA key (KID)')
option_parser.add_argument('--kid-idx', default=1, type=int, help='OTA key index (KID)')
option_parser.add_argument('--cntr', default=0, type=int, help='replay protection counter')
option_parser.add_argument('--tar', required=True, type=is_hexstr, help='Toolkit Application Reference')
option_parser.add_argument("--cntr-req", choices=CNTR_REQ.decmapping.values(), default='no_counter',
help="Counter requirement")
option_parser.add_argument('--no-ciphering', action='store_true', default=False, help='Disable ciphering')
option_parser.add_argument("--rc-cc-ds", choices=RC_CC_DS.decmapping.values(), default='cc',
help="message check (rc=redundency check, cc=crypt. checksum, ds=digital signature)")
option_parser.add_argument('--por-in-submit', action='store_true', default=False,
help='require PoR to be sent via SMS-SUBMIT')
option_parser.add_argument('--por-no-ciphering', action='store_true', default=False, help='Disable ciphering (PoR)')
option_parser.add_argument("--por-rc-cc-ds", choices=RC_CC_DS.decmapping.values(), default='cc',
help="PoR check (rc=redundency check, cc=crypt. checksum, ds=digital signature)")
option_parser.add_argument("--por-req", choices=POR_REQ.decmapping.values(), default='por_required',
help="Proof of Receipt requirements")
option_parser.add_argument('--src-addr', default='12', type=str, help='SMS source address (MSISDN)')
option_parser.add_argument('--dest-addr', default='23', type=str, help='SMS destination address (MSISDN)')
option_parser.add_argument('--timeout', default=10, type=int, help='Maximum response waiting time')
option_parser.add_argument('-a', '--apdu', action='append', required=True, type=is_hexstr, help='C-APDU to send')
class SmppHandler:
client = None
def __init__(self, host: str, port: int,
system_id: str, password: str,
ota_keyset: OtaKeyset, spi: dict, tar: bytes):
"""
Initialize connection to SMPP server and set static OTA SMS-TPDU ciphering parameters
Args:
host : Hostname or IPv4/IPv6 address of the SMPP server
port : TCP Port of the SMPP server
system_id: SMPP System-ID used by ESME (client) to bind
password: SMPP Password used by ESME (client) to bind
ota_keyset: OTA keyset to be used for SMS-TPDU ciphering
spi: Security Parameter Indicator (SPI) to be used for SMS-TPDU ciphering
tar: Toolkit Application Reference (TAR) of the targeted card application
"""
# Create and connect SMPP client
client = smpplib.client.Client(host, port, allow_unknown_opt_params=True)
client.set_message_sent_handler(self.message_sent_handler)
client.set_message_received_handler(self.message_received_handler)
client.connect()
client.bind_transceiver(system_id=system_id, password=password)
self.client = client
# Setup static OTA parameters
self.ota_dialect = OtaDialectSms()
self.ota_keyset = ota_keyset
self.tar = tar
self.spi = spi
def __del__(self):
if self.client:
self.client.unbind()
self.client.disconnect()
def message_received_handler(self, pdu):
if pdu.short_message:
logger.info("SMS-TPDU received: %s", b2h(pdu.short_message))
try:
dec = self.ota_dialect.decode_resp(self.ota_keyset, self.spi, pdu.short_message)
except ValueError:
# Retry to decoding with ciphering disabled (in case the card has problems to decode the SMS-TDPU
# we have sent, the response will contain an unencrypted error message)
spi = self.spi.copy()
spi['por_shall_be_ciphered'] = False
spi['por_rc_cc_ds'] = 'no_rc_cc_ds'
dec = self.ota_dialect.decode_resp(self.ota_keyset, spi, pdu.short_message)
logger.info("SMS-TPDU decoded: %s", dec)
self.response = dec
return None
def message_sent_handler(self, pdu):
logger.debug("SMS-TPDU sent: pdu_sequence=%s pdu_message_id=%s", pdu.sequence, pdu.message_id)
def transceive_sms_tpdu(self, tpdu: bytes, src_addr: str, dest_addr: str, timeout: int) -> tuple:
"""
Transceive SMS-TPDU. This method sends the SMS-TPDU to the SMPP server, and waits for a response. The method
returns when the response is received.
Args:
tpdu : short message content (plaintext)
src_addr : short message source address
dest_addr : short message destination address
timeout : timeout after which this method should give up waiting for a response
Returns:
tuple containing the response (plaintext)
"""
logger.info("SMS-TPDU sending: %s...", b2h(tpdu))
self.client.send_message(
# TODO: add parameters to switch source_addr_ton and dest_addr_ton between SMPP_TON_INTL and SMPP_NPI_ISDN
source_addr_ton=smpplib.consts.SMPP_TON_INTL,
source_addr=src_addr,
dest_addr_ton=smpplib.consts.SMPP_TON_INTL,
destination_addr=dest_addr,
short_message=tpdu,
# TODO: add parameters to set data_coding and esm_class
data_coding=smpplib.consts.SMPP_ENCODING_BINARY,
esm_class=smpplib.consts.SMPP_GSMFEAT_UDHI,
protocol_id=0x7f,
# TODO: add parameter to use registered delivery
# registered_delivery=True,
)
logger.info("SMS-TPDU sent, waiting for response...")
timestamp_sent=int(time.time())
self.response = None
while self.response is None:
self.client.poll()
if int(time.time()) - timestamp_sent > timeout:
raise ValueError("Timeout reached, no response SMS-TPDU received!")
return self.response
def transceive_apdu(self, apdu: bytes, src_addr: str, dest_addr: str, timeout: int) -> tuple[bytes, bytes]:
"""
Transceive APDU. This method wraps the given APDU into an SMS-TPDU, sends it to the SMPP server and waits for
the response. When the response is received, the last response data and the last status word is extracted from
the response and returned to the caller.
Args:
apdu : one or more concatenated APDUs
src_addr : short message source address
dest_addr : short message destination address
timeout : timeout after which this method should give up waiting for a response
Returns:
tuple containing the last response data and the last status word as byte strings
"""
logger.info("C-APDU sending: %s...", b2h(apdu))
# translate to Secured OTA RFM
secured = self.ota_dialect.encode_cmd(self.ota_keyset, self.tar, self.spi, apdu=apdu)
# add user data header
tpdu = b'\x02\x70\x00' + secured
# send via SMPP
response = self.transceive_sms_tpdu(tpdu, src_addr, dest_addr, timeout)
# Extract last_response_data and last_status_word from the response
sw = None
resp = None
for container in response:
if container:
container_dict = dict(container)
resp = container_dict.get('last_response_data')
sw = container_dict.get('last_status_word')
if resp is None:
raise ValueError("Response does not contain any last_response_data, no R-APDU received!")
if sw is None:
raise ValueError("Response does not contain any last_status_word, no R-APDU received!")
logger.info("R-APDU received: %s %s", resp, sw)
return h2b(resp), h2b(sw)
if __name__ == '__main__':
opts = option_parser.parse_args()
logging.basicConfig(level=logging.DEBUG if opts.verbose else logging.INFO,
format='%(asctime)s %(levelname)s %(message)s',
datefmt='%Y-%m-%d %H:%M:%S')
if opts.kic_idx != opts.kid_idx:
logger.warning("KIC index (%s) and KID index (%s) are different (security violation, card should reject message)",
opts.kic_idx, opts.kid_idx)
ota_keyset = OtaKeyset(algo_crypt=opts.algo_crypt,
kic_idx=opts.kic_idx,
kic=h2b(opts.kic),
algo_auth=opts.algo_auth,
kid_idx=opts.kid_idx,
kid=h2b(opts.kid),
cntr=opts.cntr)
spi = {'counter' : opts.cntr_req,
'ciphering' : not opts.no_ciphering,
'rc_cc_ds': opts.rc_cc_ds,
'por_in_submit': opts.por_in_submit,
'por_shall_be_ciphered': not opts.por_no_ciphering,
'por_rc_cc_ds': opts.por_rc_cc_ds,
'por': opts.por_req}
apdu = h2b("".join(opts.apdu))
smpp_handler = SmppHandler(opts.host, opts.port, opts.system_id, opts.password, ota_keyset, spi, h2b(opts.tar))
resp, sw = smpp_handler.transceive_apdu(apdu, opts.src_addr, opts.dest_addr, opts.timeout)
print("%s %s" % (b2h(resp), b2h(sw)))

View File

@@ -1,4 +1,4 @@
Retrieving card-individual keys via CardKeyProvider
Retrieving card-individual keys via CardKeyProvider
===================================================
When working with a batch of cards, or more than one card in general, it
@@ -20,9 +20,11 @@ example develop your own CardKeyProvider that queries some kind of
database for the key material, or that uses a key derivation function to
derive card-specific key material from a global master key.
The only actual CardKeyProvider implementation included in pySim is the
`CardKeyProviderCsv` which retrieves the key material from a
[potentially encrypted] CSV file.
pySim already includes two CardKeyProvider implementations. One to retrieve
key material from a CSV file (`CardKeyProviderCsv`) and a second one that allows
to retrieve the key material from a PostgreSQL database (`CardKeyProviderPgsql`).
Both implementations equally implement a column encryption scheme that allows
to protect sensitive columns using a *transport key*
The CardKeyProviderCsv
@@ -40,11 +42,224 @@ of pySim-shell. If you do not specify a CSV file, pySim will attempt to
open a CSV file from the default location at
`~/.osmocom/pysim/card_data.csv`, and use that, if it exists.
The `CardKeyProviderCsv` is suitable to manage small amounts of key material
locally. However, if your card inventory is very large and the key material
must be made available on multiple sites, the `CardKeyProviderPgsql` is the
better option.
The CardKeyProviderPgsql
------------------------
With the `CardKeyProviderPgsql` you can use a PostgreSQL database as storage
medium. The implementation comes with a CSV importer tool that consumes the
same CSV files you would normally use with the `CardKeyProviderCsv`, so you
can just use your existing CSV files and import them into the database.
Requirements
^^^^^^^^^^^^
The `CardKeyProviderPgsql` uses the `Psycopg` PostgreSQL database adapter
(https://www.psycopg.org). `Psycopg` is not part of the default requirements
of pySim-shell and must be installed separately. `Psycopg` is available as
Python package under the name `psycopg2-binary`.
Setting up the database
^^^^^^^^^^^^^^^^^^^^^^^
From the perspective of the database, the `CardKeyProviderPgsql` has only
minimal requirements. You do not have to create any tables in advance. An empty
database and at least one user that may create, alter and insert into tables is
sufficient. However, for increased reliability and as a protection against
incorrect operation, the `CardKeyProviderPgsql` supports a hierarchical model
with three users (or roles):
* **admin**:
This should be the owner of the database. It is intended to be used for
administrative tasks like adding new tables or adding new columns to existing
tables. This user should not be used to insert new data into tables or to access
data from within pySim-shell using the `CardKeyProviderPgsql`
* **importer**:
This user is used when feeding new data into an existing table. It should only
be able to insert new rows into existing tables. It should not be used for
administrative tasks or to access data from within pySim-shell using the
`CardKeyProviderPgsql`
* **reader**:
To access data from within pySim shell using the `CardKeyProviderPgsql` the
reader user is the correct one to use. This user should have no write access
to the database or any of the tables.
Creating a config file
^^^^^^^^^^^^^^^^^^^^^^
The default location for the config file is `~/.osmocom/pysim/card_data_pgsql.cfg`
The file uses `yaml` syntax and should look like the example below:
::
host: "127.0.0.1"
db_name: "my_database"
table_names:
- "uicc_keys"
- "euicc_keys"
db_users:
admin:
name: "my_admin_user"
pass: "my_admin_password"
importer:
name: "my_importer_user"
pass: "my_importer_password"
reader:
name: "my_reader_user"
pass: "my_reader_password"
This file is used by pySim-shell and by the importer tool. Both expect the file
in the aforementioned location. In case you want to store the file in a
different location you may use the `--pgsql` commandline option to provide a
custom config file path.
The hostname and the database name for the PostgreSQL database is set with the
`host` and `db_name` fields. The field `db_users` sets the user names and
passwords for each of the aforementioned users (or roles). In case only a single
admin user is used, all three entries may be populated with the same user name
and password (not recommended)
The field `table_names` sets the tables that the `CardKeyProviderPgsql` shall
use to query to locate card key data. You can set up as many tables as you
want, `CardKeyProviderPgsql` will query them in order, one by one until a
matching entry is found.
NOTE: In case you do not want to disclose the admin and the importer credentials
to pySim-shell you may remove those lines. pySim-shell will only require the
`reader` entry under `db_users`.
Using the Importer
^^^^^^^^^^^^^^^^^^
Before data can be imported, you must first create a database table. Tables
are created with the provided importer tool, which can be found under
`contrib/csv-to-pgsql.py`. This tool is used to create the database table and
read the data from the provided CSV file into the database.
As mentioned before, all CSV file formats that work with `CardKeyProviderCsv`
may be used. To demonstrate how the import process works, let's assume you want
to import a CSV file format that looks like the following example. Let's also
assume that you didn't get the Global Platform keys from your card vendor for
this batch of UICC cards, so your CSV file lacks the columns for those fields.
::
"id","imsi","iccid","acc","pin1","puk1","pin2","puk2","ki","opc","adm1"
"card1","999700000000001","8900000000000000001","0001","1111","11111111","0101","01010101","11111111111111111111111111111111","11111111111111111111111111111111","11111111"
"card2","999700000000002","8900000000000000002","0002","2222","22222222","0202","02020202","22222222222222222222222222222222","22222222222222222222222222222222","22222222"
"card3","999700000000003","8900000000000000003","0003","3333","22222222","0303","03030303","33333333333333333333333333333333","33333333333333333333333333333333","33333333"
Since this is your first import, the database still lacks the table. To
instruct the importer to create a new table, you may use the `--create-table`
option. You also have to pick an appropriate name for the table. Any name may
be chosen as long as it contains the string `uicc_keys` or `euicc_keys`,
depending on the type of data (`UICC` or `eUICC`) you intend to store in the
table. The creation of the table is an administrative task and can only be done
with the `admin` user. The `admin` user is selected using the `--admin` switch.
::
$ PYTHONPATH=../ ./csv-to-pgsql.py --csv ./csv-to-pgsql_example_01.csv --table-name uicc_keys --create-table --admin
INFO: CSV file: ./csv-to-pgsql_example_01.csv
INFO: CSV file columns: ['ID', 'IMSI', 'ICCID', 'ACC', 'PIN1', 'PUK1', 'PIN2', 'PUK2', 'KI', 'OPC', 'ADM1']
INFO: Using config file: /home/user/.osmocom/pysim/card_data_pgsql.cfg
INFO: Database host: 127.0.0.1
INFO: Database name: my_database
INFO: Database user: my_admin_user
INFO: New database table created: uicc_keys
INFO: Database table: uicc_keys
INFO: Database table columns: ['ICCID', 'IMSI']
INFO: Adding missing columns: ['PIN2', 'PUK1', 'PUK2', 'ACC', 'ID', 'PIN1', 'ADM1', 'KI', 'OPC']
INFO: Changes to table uicc_keys committed!
The importer has created a new table with the name `uicc_keys`. The table is
now ready to be filled with data.
::
$ PYTHONPATH=../ ./csv-to-pgsql.py --csv ./csv-to-pgsql_example_01.csv --table-name uicc_keys
INFO: CSV file: ./csv-to-pgsql_example_01.csv
INFO: CSV file columns: ['ID', 'IMSI', 'ICCID', 'ACC', 'PIN1', 'PUK1', 'PIN2', 'PUK2', 'KI', 'OPC', 'ADM1']
INFO: Using config file: /home/user/.osmocom/pysim/card_data_pgsql.cfg
INFO: Database host: 127.0.0.1
INFO: Database name: my_database
INFO: Database user: my_importer_user
INFO: Database table: uicc_keys
INFO: Database table columns: ['ICCID', 'IMSI', 'PIN2', 'PUK1', 'PUK2', 'ACC', 'ID', 'PIN1', 'ADM1', 'KI', 'OPC']
INFO: CSV file import done, 3 rows imported
INFO: Changes to table uicc_keys committed!
A quick `SELECT * FROM uicc_keys;` at the PostgreSQL console should now display
the contents of the CSV file you have fed into the importer.
Let's now assume that with your next batch of UICC cards your vendor includes
the Global Platform keys so your CSV format changes. It may now look like this:
::
"id","imsi","iccid","acc","pin1","puk1","pin2","puk2","ki","opc","adm1","scp02_dek_1","scp02_enc_1","scp02_mac_1"
"card4","999700000000004","8900000000000000004","0004","4444","44444444","0404","04040404","44444444444444444444444444444444","44444444444444444444444444444444","44444444","44444444444444444444444444444444","44444444444444444444444444444444","44444444444444444444444444444444"
"card5","999700000000005","8900000000000000005","0005","4444","55555555","0505","05050505","55555555555555555555555555555555","55555555555555555555555555555555","55555555","55555555555555555555555555555555","55555555555555555555555555555555","55555555555555555555555555555555"
"card6","999700000000006","8900000000000000006","0006","4444","66666666","0606","06060606","66666666666666666666666666666666","66666666666666666666666666666666","66666666","66666666666666666666666666666666","66666666666666666666666666666666","66666666666666666666666666666666"
When importing data from an updated CSV format the database table also has
to be updated. This is done using the `--update-columns` switch. Like when
creating new tables, this operation also requires admin privileges, so the
`--admin` switch is required again.
::
$ PYTHONPATH=../ ./csv-to-pgsql.py --csv ./csv-to-pgsql_example_02.csv --table-name uicc_keys --update-columns --admin
INFO: CSV file: ./csv-to-pgsql_example_02.csv
INFO: CSV file columns: ['ID', 'IMSI', 'ICCID', 'ACC', 'PIN1', 'PUK1', 'PIN2', 'PUK2', 'KI', 'OPC', 'ADM1', 'SCP02_DEK_1', 'SCP02_ENC_1', 'SCP02_MAC_1']
INFO: Using config file: /home/user/.osmocom/pysim/card_data_pgsql.cfg
INFO: Database host: 127.0.0.1
INFO: Database name: my_database
INFO: Database user: my_admin_user
INFO: Database table: uicc_keys
INFO: Database table columns: ['ICCID', 'IMSI', 'PIN2', 'PUK1', 'PUK2', 'ACC', 'ID', 'PIN1', 'ADM1', 'KI', 'OPC']
INFO: Adding missing columns: ['SCP02_ENC_1', 'SCP02_MAC_1', 'SCP02_DEK_1']
INFO: Changes to table uicc_keys committed!
When the new table columns are added, the import may be continued like the
first one:
::
$ PYTHONPATH=../ ./csv-to-pgsql.py --csv ./csv-to-pgsql_example_02.csv --table-name uicc_keys
INFO: CSV file: ./csv-to-pgsql_example_02.csv
INFO: CSV file columns: ['ID', 'IMSI', 'ICCID', 'ACC', 'PIN1', 'PUK1', 'PIN2', 'PUK2', 'KI', 'OPC', 'ADM1', 'SCP02_DEK_1', 'SCP02_ENC_1', 'SCP02_MAC_1']
INFO: Using config file: /home/user/.osmocom/pysim/card_data_pgsql.cfg
INFO: Database host: 127.0.0.1
INFO: Database name: my_database
INFO: Database user: my_importer_user
INFO: Database table: uicc_keys
INFO: Database table columns: ['ICCID', 'IMSI', 'PIN2', 'PUK1', 'PUK2', 'ACC', 'ID', 'PIN1', 'ADM1', 'KI', 'OPC', 'SCP02_ENC_1', 'SCP02_MAC_1', 'SCP02_DEK_1']
INFO: CSV file import done, 3 rows imported
INFO: Changes to table uicc_keys committed!
On the PostgreSQL console a `SELECT * FROM uicc_keys;` should now show the
imported data with the added columns. All important data should now also be
available from within pySim-shell via the `CardKeyProviderPgsql`.
Column-Level CSV encryption
~~~~~~~~~~~~~~~~~~~~~~~~~~~
---------------------------
pySim supports column-level CSV encryption. This feature will make sure
that your key material is not stored in plaintext in the CSV file.
that your key material is not stored in plaintext in the CSV file (or
database).
The encryption mechanism uses AES in CBC mode. You can use any key
length permitted by AES (128/192/256 bit).
@@ -72,6 +287,8 @@ by all columns of the set:
* `SCP03_ISDA` is a group alias for `SCP03_ENC_ISDA`, `SCP03_MAC_ISDA`, `SCP03_DEK_ISDA`
* `SCP03_ISDR` is a group alias for `SCP03_ENC_ISDR`, `SCP03_MAC_ISDR`, `SCP03_DEK_ISDR`
NOTE: When using `CardKeyProviderPqsl`, the input CSV files must be encrypted
before import.
Field naming
------------
@@ -82,22 +299,22 @@ Field naming
* For look-up of eUICC specific key material (like SCP03 keys for the
ISD-R, ECASD), pySim uses the `EID` field as lookup key.
As soon as the CardKeyProviderCsv finds a line (row) in your CSV where
the ICCID or EID match, it looks for the column containing the requested
data.
As soon as the CardKeyProvider finds a line (row) in your CSV file
(or database) where the ICCID or EID match, it looks for the column containing
the requested data.
ADM PIN
~~~~~~~
^^^^^^^
The `verify_adm` command will attempt to look up the `ADM1` column
indexed by the ICCID of the SIM/UICC.
SCP02 / SCP03
~~~~~~~~~~~~~
^^^^^^^^^^^^^
SCP02 and SCP03 each use key triplets consisting if ENC, MAC and DEK
SCP02 and SCP03 each use key triplets consisting of ENC, MAC and DEK
keys. For more details, see the applicable GlobalPlatform
specifications.

View File

@@ -13,6 +13,7 @@
import os
import sys
sys.path.insert(0, os.path.abspath('..'))
sys.path.insert(0, os.path.abspath('.')) # for local extensions (pysim_fs_sphinx, ...)
# -- Project information -----------------------------------------------------
@@ -39,7 +40,8 @@ extensions = [
"sphinx.ext.autodoc",
"sphinxarg.ext",
"sphinx.ext.autosectionlabel",
"sphinx.ext.napoleon"
"sphinx.ext.napoleon",
"pysim_fs_sphinx",
]
# Add any paths that contain templates here, relative to this directory.
@@ -64,3 +66,25 @@ html_theme = 'alabaster'
html_static_path = ['_static']
autoclass_content = 'both'
# Mock optional server-side deps of es2p and http_json_api/es9p,
# so that autodoc can import and document those modules.
autodoc_mock_imports = ['klein', 'twisted']
# Workaround for duplicate label warnings:
# https://github.com/sphinx-doc/sphinx-argparse/issues/14
#
# sphinxarg.ext generates generic sub-headings ("Named arguments",
# "Positional arguments", "Sub-commands", "General options", ...) for every
# argparse command/tool. These repeat across many files and trigger tons
# of autosectionlabel duplicate-label warnings - suppress them.
autosectionlabel_maxdepth = 3
suppress_warnings = [
'autosectionlabel.filesystem',
'autosectionlabel.saip-tool',
'autosectionlabel.shell',
'autosectionlabel.smpp2sim',
'autosectionlabel.smpp-ota-tool',
'autosectionlabel.suci-keytool',
'autosectionlabel.trace',
]

View File

@@ -39,6 +39,7 @@ pySim consists of several parts:
:caption: Contents:
shell
filesystem
trace
legacy
smpp2sim
@@ -48,6 +49,7 @@ pySim consists of several parts:
sim-rest
suci-keytool
saip-tool
smpp-ota-tool
Indices and tables

View File

@@ -205,7 +205,7 @@ Specifically, pySim-read will dump the following:
* DF.GSM
* EF,IMSI
* EF.IMSI
* EF.GID1
* EF.GID2
* EF.SMSP

836
docs/put_key-tutorial.rst Normal file
View File

@@ -0,0 +1,836 @@
Guide: Managing GP Keys
=======================
Most of today's smartcards follow the GlobalPlatform Card Specification and the included Security Domain model.
UICCs and eUCCCs are no exception here.
The Security Domain acts as an on-card representative of a card authority or administrator. It is used to perform tasks
like the installation of applications or the provisioning and rotation of secure channel keys. It also acts as a secure
key storage and offers all kinds of cryptographic services to applications that are installed under a specific
Security Domain (see also GlobalPlatform Card Specification, section 7).
In this tutorial, we will show how to work with the key material (keysets) stored inside a Security Domain and how to
rotate (replace) existing keys. We will also show how to provision new keys.
.. warning:: Making changes to keysets requires extreme caution as misconfigured keysets may lock you out permanently.
It's also strongly recommended to maintain at least one backup keyset that you can use as fallback in case
the primary keyset becomes unusable for some reason.
Selecting a Security Domain
~~~~~~~~~~~~~~~~~~~~~~~~~~~
A typical smartcard, such as an UICC will have one primary Security Domain, called the Issuer Security Domain (ISD).
When working with those cards, the ISD will show up in the UICC filesystem tree as `ADF.ISD` and can be selected like
any other file.
::
pySIM-shell (00:MF)> select ADF.ISD
{
"application_id": "a000000003000000",
"proprietary_data": {
"maximum_length_of_data_field_in_command_message": 255
}
}
When working with eUICCs, multiple Security Domains are involved. The model is fundamentally different from the classic
model with one primary Security Domain (ISD). In the case of eUICCs, an ISD-R (Issuer Security Domain - Root) and an
ISD-P (Issuer Security Domain - Profile) exist (see also: GSMA SGP.02, section 2.2.1).
The ISD-P is established by the ISD-R during the profile installation and serves as a secure container for an eSIM
profile. Within the ISD-P the eSIM profile establishes a dedicated Security Domain called `MNO-SD` (see also GSMA
SGP.02, section 2.2.4). This `MNO-SD` is comparable to the Issuer Security Domain (ISD) we find on UICCs. The AID of
`MNO-SD` is either the default AID for the Issuer Security Domain (see also GlobalPlatform, section H.1.3) or a
different value specified by the provider of the eSIM profile.
Since the AID of the `MNO-SD` is not a fixed value, it is not known by `pySim-shell`. This means there will be no
`ADF.ISD` file shown in the file system, but we can simply select the `ADF.ISD-R` first and then select the `MNO-SD`
using a raw APDU. In the following example we assume that the default AID (``a000000151000000``) is used The APDU
would look like this: ``00a4040408`` + ``a000000151000000`` + ``00``
::
pySIM-shell (00:MF)> select ADF.ISD-R
{
"application_id": "a0000005591010ffffffff8900000100",
"proprietary_data": {
"maximum_length_of_data_field_in_command_message": 255
},
"isdr_proprietary_application_template": {
"supported_version_number": "020300"
}
}
pySIM-shell (00:MF/ADF.ISD-R)> apdu 00a4040408a00000015100000000
SW: 9000, RESP: 6f108408a000000151000000a5049f6501ff
After that, the prompt will still show the `ADF.ISD-R`, but we are actually in `ADF.ISD` and the standard GlobalPlatform
operations like `establish_scpXX`, `get_data`, and `put_key` should work. By doing this, we simply have tricked
`pySim-shell` into making the GlobalPlatform related commands available for some other Security Domain we are not
interested in. With the raw APDU we then have swapped out the Security Domain under the hood. The same workaround can
be applied to any Security Domain, provided that the AID is known to the user.
Establishing a secure channel
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Before we can make changes to the keysets in the currently selected Security Domain we must first establish a secure
channel with that Security Domain. In the following examples we will use `SCP02` (see also GlobalPlatform Card
Specification, section E.1.1) and `SCP03` (see also GlobalPlatform Card Specification Amendment D) to establish the
secure channel. `SCP02` is slightly older than `SCP03`. The main difference between the two is that `SCP02` uses 3DES
while `SCP03` is based on AES.
.. warning:: Secure channel protocols like `SCP02` and `SCP03` may manage an error counter to count failed login
attempts. This means attempting to establish a secure channel with a wrong keyset multiple times may lock
you out permanently. Double check the applied keyset before attempting to establish a secure channel.
.. warning:: The key values used in the following examples are random key values used for illustration purposes only.
Each UICC or eSIM profile is shipped with individual keys, which means that the keys used below will not
work with your UICC or eSIM profile. You must replace the key values with the values you have received
from your UICC vendor or eSIM profile provider.
Example: `SCP02`
----------------
In the following example, we assume that we want to establish a secure channel with the ISD of a `sysmoUSIM-SJA5` UICC.
Along with the card we have received the following keyset:
+---------+----------------------------------+
| Keyname | Keyvalue |
+=========+==================================+
| ENC/KIC | F09C43EE1A0391665CC9F05AF4E0BD10 |
+---------+----------------------------------+
| MAC/KID | 01981F4A20999F62AF99988007BAF6CA |
+---------+----------------------------------+
| DEK/KIK | 8F8AEE5CDCC5D361368BC45673D99195 |
+---------+----------------------------------+
This keyset is tied to the key version number KVN 122 and is configured as a DES keyset. We can use this keyset to
establish a secure channel using the SCP02 Secure Channel Protocol.
::
pySIM-shell (00:MF/ADF.ISD)> establish_scp02 --key-enc F09C43EE1A0391665CC9F05AF4E0BD10 --key-mac 01981F4A20999F62AF99988007BAF6CA --key-dek 8F8AEE5CDCC5D361368BC45673D99195 --key-ver 112 --security-level 3
Successfully established a SCP02[03] secure channel
Example: `SCP03`
----------------
The establishment of a secure channel via SCP03 works just the same. In the following example we will establish a
secure channel to the `MNO-SD` of an eSIM profile. The SCP03 keyset we use is tied to KVN 48 and looks like this:
+---------+------------------------------------------------------------------+
| Keyname | Keyvalue |
+=========+==================================================================+
| ENC/KIC | 63af517c29ad6ac6fcadfe6ac8a3c8a041d8141c7eb845ef1cba6112a325e430 |
+---------+------------------------------------------------------------------+
| MAC/KID | 54b9ad6713ae922f54014ed762132e7b59bdcd2a2a6beba98fb9afe6b4df27e1 |
+---------+------------------------------------------------------------------+
| DEK/KIK | cbb933ba2389da93c86c112739cd96389139f16c6f80f7d16bf3593e407ca893 |
+---------+------------------------------------------------------------------+
We assume that the `MNO-SD` is already selected (see above). We may now establish the SCP03 secure channel:
::
pySIM-shell (00:MF/ADF.ISD-R)> establish_scp03 --key-enc 63af517c29ad6ac6fcadfe6ac8a3c8a041d8141c7eb845ef1cba6112a325e430 --key-mac 54b9ad6713ae922f54014ed762132e7b59bdcd2a2a6beba98fb9afe6b4df27e1 --key-dek cbb933ba2389da93c86c112739cd96389139f16c6f80f7d16bf3593e407ca893 --key-ver 48 --security-level 3
Successfully established a SCP03[03] secure channel
Understanding Keysets
~~~~~~~~~~~~~~~~~~~~~
Before making any changes to keysets, it is recommended to check the status of the currently installed keysets. To do
so, we use the `get_data` command to retrieve the `key_information`. This command does not require the establishment of
a secure channel. We also cannot read back the key values themselves, but we get a summary of the installed keys
together with their KVN numbers, IDs, algorithm and key length values.
Example: `key_information` from a `sysmoISIM-SJA5`:
::
pySIM-shell (SCP02[03]:00:MF/ADF.ISD)> get_data key_information
{
"key_information": [
{
"key_information_data": {
"key_identifier": 1,
"key_version_number": 112,
"key_types": [
{
"type": "des",
"length": 16
}
]
}
},
{
"key_information_data": {
"key_identifier": 2,
"key_version_number": 112,
"key_types": [
{
"type": "des",
"length": 16
}
]
}
},
{
"key_information_data": {
"key_identifier": 3,
"key_version_number": 112,
"key_types": [
{
"type": "des",
"length": 16
}
]
}
},
{
"key_information_data": {
"key_identifier": 1,
"key_version_number": 1,
"key_types": [
{
"type": "des",
"length": 16
}
]
}
},
{
"key_information_data": {
"key_identifier": 2,
"key_version_number": 1,
"key_types": [
{
"type": "des",
"length": 16
}
]
}
},
{
"key_information_data": {
"key_identifier": 3,
"key_version_number": 1,
"key_types": [
{
"type": "des",
"length": 16
}
]
}
},
{
"key_information_data": {
"key_identifier": 1,
"key_version_number": 2,
"key_types": [
{
"type": "des",
"length": 16
}
]
}
},
{
"key_information_data": {
"key_identifier": 2,
"key_version_number": 2,
"key_types": [
{
"type": "des",
"length": 16
}
]
}
},
{
"key_information_data": {
"key_identifier": 3,
"key_version_number": 2,
"key_types": [
{
"type": "des",
"length": 16
}
]
}
},
{
"key_information_data": {
"key_identifier": 1,
"key_version_number": 47,
"key_types": [
{
"type": "des",
"length": 16
}
]
}
},
{
"key_information_data": {
"key_identifier": 2,
"key_version_number": 47,
"key_types": [
{
"type": "des",
"length": 16
}
]
}
},
{
"key_information_data": {
"key_identifier": 3,
"key_version_number": 47,
"key_types": [
{
"type": "des",
"length": 16
}
]
}
}
]
}
Example: `key_information` from a `sysmoEUICC1-C2T`:
::
pySIM-shell (SCP03[03]:00:MF/ADF.ISD-R)> get_data key_information
{
"key_information": [
{
"key_information_data": {
"key_identifier": 3,
"key_version_number": 50,
"key_types": [
{
"type": "aes",
"length": 32
}
]
}
},
{
"key_information_data": {
"key_identifier": 2,
"key_version_number": 50,
"key_types": [
{
"type": "aes",
"length": 32
}
]
}
},
{
"key_information_data": {
"key_identifier": 1,
"key_version_number": 50,
"key_types": [
{
"type": "aes",
"length": 32
}
]
}
},
{
"key_information_data": {
"key_identifier": 2,
"key_version_number": 64,
"key_types": [
{
"type": "aes",
"length": 16
}
]
}
},
{
"key_information_data": {
"key_identifier": 1,
"key_version_number": 64,
"key_types": [
{
"type": "tls_psk",
"length": 16
}
]
}
}
]
}
The output from those two examples above may seem lengthy, but in order to move on and to provision own keys
successfully, it is important to understand each aspect of it.
Key Version Number (KVN)
------------------------
Each key is associated with a Key Version Number (KVN). Multiple keys that share the same KVN belong to the same
keyset. In the first example above we can see that four keysets with KVN numbers 112, 1, 2 and 47 are provisioned.
In the second example we see two keysets. One with KVN 50 and one with KVN 64.
The term "Key Version Number" is misleading as this number is not really a version number. It's actually a unique
identifier for a specific keyset that also defines with which Secure Channel Protocol a key can be used. This means
that the KVN is not just an arbitrary number. The following (incomplete) table gives a hint which KVN numbers may be
used with which Secure Channel Protocol.
+-----------+-------------------------------------------------------+
| KVN range | Secure Channel Protocol |
+===========+=======================================================+
| 1-15 | reserved for `SCP80` (OTA SMS) |
+-----------+-------------------------------------------------------+
| 17 | reserved for DAP specified in ETSI TS 102 226 |
+-----------+-------------------------------------------------------+
| 32-47 | reserved for `SCP02` |
+-----------+-------------------------------------------------------+
| 48-63 | reserved for `SCP03` |
+-----------+-------------------------------------------------------+
| 64-79 | reserved for `SCP81` (GSMA SGP.02, section 2.2.5.1) |
+-----------+-------------------------------------------------------+
| 112 | Token key (RSA public or DES, also used with `SCP02`) |
+-----------+-------------------------------------------------------+
| 113 | Receipt key (DES) |
+-----------+-------------------------------------------------------+
| 115 | DAP verification key (RS public or DES) |
+-----------+-------------------------------------------------------+
| 116 | reserved for CASD |
+-----------+-------------------------------------------------------+
| 117 | 16-byte DES key for Ciphered Load File Data Block |
+-----------+-------------------------------------------------------+
| 255 | reserved for ISD with SCP02 without SCP80 support |
+-----------+-------------------------------------------------------+
With that we can now understand that in the first example, the first and the last keyset is intended to be used with
`SCP02` and that the second and the third keyset is intended to be used with `SCP80` (OTA SMS). In the second example we
can see that the first keyset is intended to be used with `SCP03`, wheres the second should be usable with `SCP81`.
Key Identifier
--------------
Each keyset consists of a number of keys, where each key has a different Key Identifier. The Key Identifier is usually
an incrementing number that starts counting at 1. The Key Identifier is used to distinguish the keys within the keyset.
The exact number of keys and their attributes depends on the secure channel protocol for which the keyset is intended
for. Each secure channel protocol may have its specific requirements on how many keys of which which type, length or
Key Identifier have to be present.
However, almost all of the classic secure channel protocols (including `SCP02`, `SCP03` and `SCP81`) make use of the
following three-key scheme:
+----------------+---------+---------------------------------------+
| Key Identifier | Keyname | Purpose |
+================+=========+=======================================+
| 1 | ENC/KIC | encryption/decryption |
+----------------+---------+---------------------------------------+
| 2 | MAC/KID | cryptographic checksumming/signing |
+----------------+---------+---------------------------------------+
| 3 | DEK/KIK | encryption/decryption of key material |
+----------------+---------+---------------------------------------+
In this case, all three keys share the same length and are used with the same algorithm. The key length is often used
to implicitly select sub-types of an algorithm. (e.g. a 16 byte key of type `aes` is associated with `AES128`, where a 32
byte key would be associated with `AES256`).
The second example shows that different schemes are possible. The `SCP80` keyset from the second example uses a scheme
that works with two keys:
+----------------+---------+---------------------------------------+
| Key Identifier | Keyname | Purpose |
+================+=========+=======================================+
| 1 | TLS-PSK | pre-shared key used for TLS |
+----------------+---------+---------------------------------------+
| 2 | DEK/KIK | encryption/decryption of key material |
+----------------+---------+---------------------------------------+
It should also be noted that the order in which keysets and keys appear is an implementation detail of the UICC/eUICC
O/S. The order has no influence on how a keyset is interpreted. Only the Key Version Number (KVN) and the Key Identifier
matter.
Rotating a keyset
~~~~~~~~~~~~~~~~~
Rotating keys is one of the most basic tasks one might want to perform on an UICC/eUICC before using it productively. In
the following example we will illustrate how key rotation can be done. When rotating keys, only the key itself may
change. For example it is not possible to change the key length or the algorithm used (see also GlobalPlatform Card
Specification, section 11.8.2.3.3). Any key of the current Security Domain can be rotated, this also includes the key
that was used to establish the secure channel.
In the following example we assume that the Security Domain is selected and a secure channel is already established. We
intend to rotate the keyset with KVN 112. Since this keyset uses triple DES keys with a key length of 16, we must
replace it with a keyset with keys of the same nature.
The new keyset shall look like this:
+----------------+---------+----------------------------------+
| Key Identifier | Keyname | Keyvalue |
+================+=========+==================================+
| 1 | ENC/KIC | 542C37A6043679F2F9F71116418B1CD5 |
+----------------+---------+----------------------------------+
| 2 | MAC/KID | 34F11BAC8E5390B57F4E601372339E3C |
+----------------+---------+----------------------------------+
| 3 | DEK/KIK | 5524F4BECFE96FB63FC29D6BAAC6058B |
+----------------+---------+----------------------------------+
When passing the keys to the `put_key` commandline, we set the Key Identifier of the first key using the `--key-id`
parameter. This Key Identifier will be valid for the first key (KIC) we pass. For all consecutive keys, the Key
Identifier will be incremented automatically (see also GlobalPlatform Card Specification, section 11.8.2.2). To Ensure
that the new KIC, KID and KIK keys get the correct Key Identifiers, it is crucial to maintain order when passing the
keys in the `--key-data` arguments. It is also important that each `--key-data` argument is preceded by a `--key-type`
argument that sets the algorithm correctly (`des` in this case).
Finally we have to target the keyset we want to rotate by its KVN. The `--old-key-version-nr` argument is set to 112
as this identifies the keyset we want to rotate. The `--key-version-nr` is also set to 112 as we do not want
KVN to be changed in this example. Changing the KVN while rotating a keyset is possible. In case the KVN has to change
for some reason, the new KVN must be selected carefully to keep the key usable with the associated Secure Channel
Protocol.
The commandline that matches the keyset we had laid out above looks like this:
::
pySIM-shell (SCP02[03]:00:MF/ADF.ISD)> put_key --key-id 1 --key-type des --key-data 542C37A6043679F2F9F71116418B1CD5 --key-type des --key-data 34F11BAC8E5390B57F4E601372339E3C --key-type des --key-data 5524F4BECFE96FB63FC29D6BAAC6058B --old-key-version-nr 112 --key-version-nr 112
After executing this put_key commandline, the keyset identified by KVN 122 is equipped with new keys. We can use
`get_data key_information` to inspect the currently installed keysets. The output should appear unchanged as
we only swapped out the keys. All other parameters, identifiers etc. should remain constant.
.. warning:: It is technically possible to rotate a keyset in a `non atomic` way using one `put_key` commandline for
each key. However, in case the targeted keyset is the one used to establish the current secure channel,
this method should not be used since, depending on the UICC/eUICC model, half-written key material may
interrupt the current secure channel.
Removing a keyset
~~~~~~~~~~~~~~~~~
In some cases it is necessary to remove a keyset entirely. This can be done with the `delete_key` command. Here it is
important to understand that `delete_key` only removes one specific key from a specific keyset. This means that you
need to run a separate `delete_key` command for each key inside a keyset.
In the following example we assume that the Security Domain is selected and a secure channel is already established. We
intend to remove the keyset with KVN 112. This keyset consists of three keys.
::
pySIM-shell (SCP02[03]:00:MF/ADF.ISD)> delete_key --key-ver 112 --key-id 1
pySIM-shell (SCP02[03]:00:MF/ADF.ISD)> delete_key --key-ver 112 --key-id 2
pySIM-shell (SCP02[03]:00:MF/ADF.ISD)> delete_key --key-ver 112 --key-id 3
To verify that the keyset has been deleted properly, we can use the `get_data key_information` command to inspect the
current status of the installed keysets. We should see that the key with KVN 112 is no longer present.
Adding a keyset
~~~~~~~~~~~~~~~
In the following we will discuss how to add an entirely new keyset. The procedure is almost identical with the key
rotation procedure we have already discussed and it is assumed that all details about the key rotation are understood.
In this section we will go into more detail and illustrate how to provision new 3DES, `AES128` and `AES256` keysets.
It is important to keep in mind that storage space on smartcard is a precious resource. In many cases the amount of
keysets that a Security Domain can store is limited. In some situations you may be forced to sacrifice one of your
existing keysets in favor of a new keyset.
The main difference between key rotation and the adding of new keys is that we do not simply replace an existing key.
Instead an entirely new key is programmed into the Security Domain. Therefore the `put_key` commandline will have no
`--old-key-version-nr` parameter. From the commandline perspective, this is already the only visible difference from a
commandline that simply rotates a keyset. Since we are writing an entirely new keyset, we are free to chose the
algorithm and the key length within the parameter range permitted by the targeted secure channel protocol. Otherwise
the same rules apply.
For reference, it should be mentioned that it is also possible to add or rotate keyset using multiple `put_key`
commandlines. In this case one `put_key` commandline for each key is used. Each commandline will specify `--key-id` and
`--key-version-nr` and one `--key-type` and `--key-data` tuple. However, when rotating or adding a keyset step-by-step,
the whole process happens in a `non-atomic` way, which is less reliable. Therefore we will favor the `atomic method`
In the following examples we assume that the Security Domain is selected and a secure channel is already established.
Example: `3DES` key for `SCP02`
-------------------------------
Let's assume we want to provision a new 3DES keyset that we can use for SCP02. The keyset shall look like this:
+----------------+---------+----------------------------------+
| Key Identifier | Keyname | Keyvalue |
+================+=========+==================================+
| 1 | ENC/KIC | 542C37A6043679F2F9F71116418B1CD5 |
+----------------+---------+----------------------------------+
| 2 | MAC/KID | 34F11BAC8E5390B57F4E601372339E3C |
+----------------+---------+----------------------------------+
| 3 | DEK/KIK | 5524F4BECFE96FB63FC29D6BAAC6058B |
+----------------+---------+----------------------------------+
The keyset shall be a associated with the KVN 46. We have made sure before that KVN 46 is still unused and that this
KVN number is actually suitable for SCP02 keys. As we are using 3DES, it is obvious that we have to pass 3 keys with 16
byte length.
To program the key, we may use the following commandline. As we can see, this commandline is almost the exact same as
the one from the key rotation example where we were rotating a 3DES key. The only difference is that we didn't specify
an old KVN number and that we have chosen a different KVN.
::
pySIM-shell (SCP02[03]:00:MF/ADF.ISD)> put_key --key-id 1 --key-type des --key-data 542C37A6043679F2F9F71116418B1CD5 --key-type des --key-data 34F11BAC8E5390B57F4E601372339E3C --key-type des --key-data 5524F4BECFE96FB63FC29D6BAAC6058B --key-version-nr 46
In case of success, the keyset should appear in the `key_information` among the other keysets that are already present.
::
pySIM-shell (SCP02[03]:00:MF/ADF.ISD)> get_data key_information
{
"key_information": [
{
"key_information_data": {
"key_identifier": 1,
"key_version_number": 46,
"key_types": [
{
"type": "des",
"length": 16
}
]
}
},
{
"key_information_data": {
"key_identifier": 2,
"key_version_number": 46,
"key_types": [
{
"type": "des",
"length": 16
}
]
}
},
{
"key_information_data": {
"key_identifier": 3,
"key_version_number": 46,
"key_types": [
{
"type": "des",
"length": 16
}
]
}
},
...
]
}
Example: `AES128` key for `SCP80`
---------------------------------
In this example we intend to provision a new `AES128` keyset that we can use with SCP80 (OTA SMS). The keyset shall look
like this:
+----------------+---------+----------------------------------+
| Key Identifier | Keyname | Keyvalue |
+================+=========+==================================+
| 1 | ENC/KIC | 542C37A6043679F2F9F71116418B1CD5 |
+----------------+---------+----------------------------------+
| 2 | MAC/KID | 34F11BAC8E5390B57F4E601372339E3C |
+----------------+---------+----------------------------------+
| 3 | DEK/KIK | 5524F4BECFE96FB63FC29D6BAAC6058B |
+----------------+---------+----------------------------------+
In addition to that, we want to associate this key with KVN 3. We have inspected the currently installed keysets before
and made sure that KVN 3 is still unused. We are also aware that for SCP80 we may only use KVN values from 1 to 15.
For `AES128`, we specify the algorithm using the `--key-type aes` parameter. The selection between `AES128` and `AES256` is
done implicitly using the key length. Since we want to use `AES128` in this case, all three keys have a length of 16 byte.
::
pySIM-shell (SCP02[03]:00:MF/ADF.ISD)> put_key --key-id 1 --key-type aes --key-data 542C37A6043679F2F9F71116418B1CD5 --key-type aes --key-data 34F11BAC8E5390B57F4E601372339E3C --key-type aes --key-data 5524F4BECFE96FB63FC29D6BAAC6058B --key-version-nr 3
In case of success, the keyset should appear in the `key_information` among the other keysets that are already present.
::
pySIM-shell (SCP02[03]:00:MF/ADF.ISD)> get_data key_information
{
"key_information": [
{
"key_information_data": {
"key_identifier": 1,
"key_version_number": 3,
"key_types": [
{
"type": "aes",
"length": 16
}
]
}
},
{
"key_information_data": {
"key_identifier": 2,
"key_version_number": 3,
"key_types": [
{
"type": "aes",
"length": 16
}
]
}
},
{
"key_information_data": {
"key_identifier": 3,
"key_version_number": 3,
"key_types": [
{
"type": "aes",
"length": 16
}
]
}
},
...
]
}
Example: `AES256` key for `SCP03`
---------------------------------
Let's assume we want to provision a new `AES256` keyset that we can use for SCP03. The keyset shall look like this:
+----------------+---------+------------------------------------------------------------------+
| Key Identifier | Keyname | Keyvalue |
+================+=========+==================================================================+
| 1 | ENC/KIC | 542C37A6043679F2F9F71116418B1CD5542C37A6043679F2F9F71116418B1CD5 |
+----------------+---------+------------------------------------------------------------------+
| 2 | MAC/KID | 34F11BAC8E5390B57F4E601372339E3C34F11BAC8E5390B57F4E601372339E3C |
+----------------+---------+------------------------------------------------------------------+
| 3 | DEK/KIK | 5524F4BECFE96FB63FC29D6BAAC6058B5524F4BECFE96FB63FC29D6BAAC6058B |
+----------------+---------+------------------------------------------------------------------+
In addition to that, we assume that we want to associate this key with KVN 51. This KVN number falls in the range of
48 - 63 and is therefore suitable for a key that shall be usable with SCP03. We also made sure before that KVN 51 is
still unused.
With that we can go ahead and make up the following commandline:
::
pySIM-shell (SCP02[03]:00:MF/ADF.ISD)> put_key --key-id 1 --key-type aes --key-data 542C37A6043679F2F9F71116418B1CD5542C37A6043679F2F9F71116418B1CD5 --key-type aes --key-data 34F11BAC8E5390B57F4E601372339E3C34F11BAC8E5390B57F4E601372339E3C --key-type aes --key-data 5524F4BECFE96FB63FC29D6BAAC6058B5524F4BECFE96FB63FC29D6BAAC6058B --key-version-nr 51
In case of success, we should see the keyset in the `key_information`
::
pySIM-shell (SCP02[03]:00:MF/ADF.ISD)> get_data key_information
{
"key_information": [
{
"key_information_data": {
"key_identifier": 1,
"key_version_number": 51,
"key_types": [
{
"type": "aes",
"length": 32
}
]
}
},
{
"key_information_data": {
"key_identifier": 2,
"key_version_number": 51,
"key_types": [
{
"type": "aes",
"length": 32
}
]
}
},
{
"key_information_data": {
"key_identifier": 3,
"key_version_number": 51,
"key_types": [
{
"type": "aes",
"length": 32
}
]
}
},
...
]
}
Example: `AES128` key for `SCP81`
---------------------------------
In this example we will show how to provision a new `AES128` keyset for `SCP81`. We will provision this keyset under
KVN 64. The keyset we intend to apply shall look like this:
+----------------+---------+----------------------------------+
| Key Identifier | Keyname | Keyvalue |
+================+=========+==================================+
| 1 | TLS-PSK | 000102030405060708090a0b0c0d0e0f |
+----------------+---------+----------------------------------+
| 2 | DEK/KIK | 000102030405060708090a0b0c0d0e0f |
+----------------+---------+----------------------------------+
With that we can put together the following command line:
::
put_key --key-id 1 --key-type tls_psk --key-data 000102030405060708090a0b0c0d0e0f --key-type aes --key-data 000102030405060708090a0b0c0d0e0f --key-version-nr 64
In case of success, the keyset should appear in the `key_information` as follows:
::
pySIM-shell (SCP03[03]:00:MF/ADF.ISD-R)> get_data key_information
{
"key_information": [
...,
{
"key_information_data": {
"key_identifier": 2,
"key_version_number": 64,
"key_types": [
{
"type": "aes",
"length": 16
}
]
}
},
{
"key_information_data": {
"key_identifier": 1,
"key_version_number": 64,
"key_types": [
{
"type": "tls_psk",
"length": 16
}
]
}
}
]
}

267
docs/pysim_fs_sphinx.py Normal file
View File

@@ -0,0 +1,267 @@
"""
Sphinx extension: auto-generate docs/filesystem.rst from the pySim EF class hierarchy.
Hooked into Sphinx's ``builder-inited`` event so the file is always regenerated
from the live Python classes before Sphinx reads any source files.
The table of root objects to document is in SECTIONS near the top of this file.
EXCLUDED lists CardProfile/CardApplication subclasses intentionally omitted from
SECTIONS, with reasons. Both tables are read by tests/unittests/test_fs_coverage.py
to ensure every class with EF/DF content is accounted for.
"""
import importlib
import inspect
import json
import os
import sys
import textwrap
# Ensure pySim is importable when this module is loaded as a Sphinx extension
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from pySim.filesystem import (CardApplication, CardDF, CardMF, CardEF, # noqa: E402
TransparentEF, TransRecEF, LinFixedEF, CyclicEF, BerTlvEF)
from pySim.profile import CardProfile # noqa: E402
# Generic EF base classes whose docstrings describe the *type* of file
# (Transparent, LinFixed, ...) rather than a specific file's content.
# Suppress those boilerplate texts in the per-EF entries; they are only
# useful once, at the top of the document or in a dedicated glossary.
_EF_BASE_TYPES = frozenset([TransparentEF,
TransRecEF,
LinFixedEF,
CyclicEF,
BerTlvEF])
# ---------------------------------------------------------------------------
# Sections: (heading, module, class-name)
# The class must be either a CardProfile (uses .files_in_mf) or a CardDF
# subclass (uses .children).
# ---------------------------------------------------------------------------
SECTIONS = [
('MF / TS 102 221 (UICC)',
'pySim.ts_102_221', 'CardProfileUICC'),
('ADF.USIM / TS 31.102',
'pySim.ts_31_102', 'ADF_USIM'),
('ADF.ISIM / TS 31.103',
'pySim.ts_31_103', 'ADF_ISIM'),
('ADF.HPSIM / TS 31.104',
'pySim.ts_31_104', 'ADF_HPSIM'),
('DF.GSM + DF.TELECOM / TS 51.011 (SIM)',
'pySim.ts_51_011', 'CardProfileSIM'),
('CDMA / IS-820 (RUIM)',
'pySim.cdma_ruim', 'CardProfileRUIM'),
('DF.EIRENE / GSM-R',
'pySim.gsm_r', 'DF_EIRENE'),
('DF.SYSTEM / sysmocom SJA2+SJA5',
'pySim.sysmocom_sja2', 'DF_SYSTEM'),
]
# ---------------------------------------------------------------------------
# Excluded: {(module, class-name)}
# CardProfile and CardApplication subclasses that have EF/DF children but are
# intentionally absent from SECTIONS. Keeping this list explicit lets
# test_fs_coverage.py detect newly added classes that the developer forgot to
# add to either table.
# ---------------------------------------------------------------------------
EXCLUDED = {
# eUICC profiles inherit files_in_mf verbatim from CardProfileUICC; the
# eUICC-specific content lives in ISD-R / ISD-P applications, not in MF.
('pySim.euicc', 'CardProfileEuiccSGP02'),
('pySim.euicc', 'CardProfileEuiccSGP22'),
('pySim.euicc', 'CardProfileEuiccSGP32'),
# CardApplication* classes are thin wrappers that embed an ADF_* instance.
# The ADF contents are already documented via the corresponding ADF_* entry
# in SECTIONS above.
('pySim.ts_31_102', 'CardApplicationUSIM'),
('pySim.ts_31_102', 'CardApplicationUSIMnonIMSI'),
('pySim.ts_31_103', 'CardApplicationISIM'),
('pySim.ts_31_104', 'CardApplicationHPSIM'),
}
# RST underline characters ordered by nesting depth
_HEADING_CHARS = ['=', '=', '-', '~', '^', '"']
# Level 0 uses '=' with overline (page title).
# Level 1 uses '=' without overline (major sections).
# Levels 2+ use the remaining characters for DFs.
# ---------------------------------------------------------------------------
# RST formatting helpers
# ---------------------------------------------------------------------------
def _heading(title: str, level: int) -> str:
"""Return an RST heading string. Level 0 gets an overline."""
char = _HEADING_CHARS[level]
rule = char * len(title)
if level == 0:
return f'{rule}\n{title}\n{rule}\n\n'
return f'{title}\n{rule}\n\n'
def _json_default(obj):
"""Fallback serialiser: bytes -> hex, anything else -> repr."""
if isinstance(obj, (bytes, bytearray)):
return obj.hex()
return repr(obj)
def _examples_block(cls) -> str:
"""Return RST code-block examples (one per vector), or '' if none exist.
Each example is rendered as a ``json5`` code-block with the hex-encoded
binary as a ``// comment`` on the first line, followed by the decoded JSON.
``json5`` is used instead of ``json`` so that Pygments does not flag the
``//`` comment as a syntax error.
"""
vectors = []
for attr in ('_test_de_encode', '_test_decode'):
v = getattr(cls, attr, None)
if v:
vectors.extend(v)
if not vectors:
return ''
lines = ['**Examples**\n\n']
for t in vectors:
# 2-tuple: (encoded, decoded)
# 3-tuple: (encoded, record_nr, decoded) — LinFixedEF / CyclicEF
if len(t) >= 3:
encoded, record_nr, decoded = t[0], t[1], t[2]
comment = f'record {record_nr}: {encoded.lower()}'
else:
encoded, decoded = t[0], t[1]
comment = f'file: {encoded.lower()}'
json_str = json.dumps(decoded, default=_json_default, indent=2)
json_indented = textwrap.indent(json_str, ' ')
lines.append('.. code-block:: json5\n\n')
lines.append(f' // {comment}\n')
lines.append(json_indented + '\n')
lines.append('\n')
return ''.join(lines)
def _document_ef(ef: CardEF) -> str:
"""Return RST for a single EF. Uses ``rubric`` to stay out of the TOC."""
cls = type(ef)
parts = [ef.fully_qualified_path_str()]
if ef.fid:
parts.append(f'({ef.fid.upper()})')
if ef.desc:
parts.append(f'\u2014 {ef.desc}') # em-dash
title = ' '.join(parts)
lines = [f'.. rubric:: {title}\n\n']
# Only show a docstring if it is specific to this class. EFs that are
# direct instances of a base type (TransparentEF, LinFixedEF, ...) carry
# only the generic "what is a TransparentEF" boilerplate; named subclasses
# without their own __doc__ have cls.__dict__['__doc__'] == None. Either
# way, suppress the text here - it belongs at the document level, not
# repeated for every single EF entry.
doc = None if cls in _EF_BASE_TYPES else cls.__dict__.get('__doc__')
if doc:
lines.append(inspect.cleandoc(doc) + '\n\n')
examples = _examples_block(cls)
if examples:
lines.append(examples)
return ''.join(lines)
def _document_df(df: CardDF, level: int) -> str:
"""Return RST for a DF section and all its children, recursively."""
parts = [df.fully_qualified_path_str()]
if df.fid:
parts.append(f'({df.fid.upper()})')
if df.desc:
parts.append(f'\u2014 {df.desc}') # em-dash
title = ' '.join(parts)
lines = [_heading(title, level)]
cls = type(df)
doc = None if cls in (CardDF, CardMF) else cls.__dict__.get('__doc__')
if doc:
lines.append(inspect.cleandoc(doc) + '\n\n')
for child in df.children.values():
if isinstance(child, CardDF):
lines.append(_document_df(child, level + 1))
elif isinstance(child, CardEF):
lines.append(_document_ef(child))
return ''.join(lines)
# ---------------------------------------------------------------------------
# Top-level generator
# ---------------------------------------------------------------------------
def generate_filesystem_rst() -> str:
"""Walk all registered sections and return the full RST document as a string."""
out = [
'.. This file is auto-generated by docs/pysim_fs_sphinx.py — do not edit.\n\n',
_heading('Card Filesystem Reference', 0),
'This page documents all Elementary Files (EFs) and Dedicated Files (DFs) '
'implemented in pySim, organised by their location in the card filesystem.\n\n',
]
# Track already-documented classes so that DFs/EFs shared between profiles
# (e.g. DF.TELECOM / DF.GSM present in both CardProfileSIM and CardProfileRUIM)
# are only emitted once.
seen_types: set = set()
for section_title, module_path, class_name in SECTIONS:
module = importlib.import_module(module_path)
cls = getattr(module, class_name)
obj = cls()
if isinstance(obj, CardProfile):
files = obj.files_in_mf
elif isinstance(obj, CardApplication):
files = list(obj.adf.children.values())
elif isinstance(obj, CardDF):
files = list(obj.children.values())
else:
continue
# Filter out files whose class was already documented in an earlier section.
files = [f for f in files if type(f) not in seen_types]
if not files:
continue
out.append(_heading(section_title, 1))
for f in files:
seen_types.add(type(f))
if isinstance(f, CardDF):
out.append(_document_df(f, level=2))
elif isinstance(f, CardEF):
out.append(_document_ef(f))
return ''.join(out)
# ---------------------------------------------------------------------------
# Sphinx integration
# ---------------------------------------------------------------------------
def _on_builder_inited(app):
output_path = os.path.join(app.srcdir, 'filesystem.rst')
with open(output_path, 'w') as fh:
fh.write(generate_filesystem_rst())
def setup(app):
app.connect('builder-inited', _on_builder_inited)
return {'version': '0.1', 'parallel_read_safe': True}

View File

@@ -67,7 +67,7 @@ Inspecting applications
To inspect the application PE contents of an existing profile package, sub-command `info` with parameter '--apps' can
be used. This command lists out all application and their parameters in detail. This allows an application developer
to check if the applet insertaion was carried out as expected.
to check if the applet insertion was carried out as expected.
Example: Listing applications and their parameters
::

View File

@@ -68,7 +68,7 @@ Usage Examples
suci-tutorial
cap-tutorial
put_key-tutorial
Advanced Topics
---------------
@@ -602,8 +602,8 @@ This allows for easy interactive modification of records.
If this command fails before the editor is spawned, it means that the current record contents is not decodable,
and you should use the :ref:`update_record_decoded` or :ref:`update_record` command.
If this command fails after making your modificatiosn in the editor, it means that the new file contents is not
encodable; please check your input and/or us the raw :ref:`update_record` comamdn.
If this command fails after making your modifications in the editor, it means that the new file contents is not
encodable; please check your input and/or use the raw :ref:`update_record` command.
decode_hex
@@ -708,8 +708,8 @@ This allows for easy interactive modification of file contents.
If this command fails before the editor is spawned, it means that the current file contents is not decodable,
and you should use the :ref:`update_binary_decoded` or :ref:`update_binary` command.
If this command fails after making your modificatiosn in the editor, it means that the new file contents is not
encodable; please check your input and/or us the raw :ref:`update_binary` comamdn.
If this command fails after making your modifications in the editor, it means that the new file contents is not
encodable; please check your input and/or use the raw :ref:`update_binary` command.
decode_hex

179
docs/smpp-ota-tool.rst Normal file
View File

@@ -0,0 +1,179 @@
smpp-ota-tool
=============
The `smpp-ota-tool` allows users to send OTA SMS messages containing APDU scripts (RFM, RAM) via an SMPP server. The
intended audience are developers who want to test/evaluate the OTA SMS interface of a SIM/UICC/eUICC. `smpp-ota-tool`
is intended to be used as a companion tool for :ref:`pySim-smpp2sim`, however it should be usable on any other SMPP
server (such as a production SMSC of a live cellular network) as well.
From the technical perspective `smpp-ota-tool` takes the role of an SMPP ESME. It takes care of the encoding, encryption
and checksumming (signing) of the RFM/RAM OTA SMS and eventually submits it to the SMPP server. The program then waits
for a response. The response is automatically parsed and printed on stdout. This makes the program also suitable to be
called from shell scripts.
.. note:: In the following we will we will refer to `SIM` as one of the following: `SIM`, `USIM`, `ISIM`, `UICC`,
`eUICC`, `eSIM`.
Applying OTA keys
~~~~~~~~~~~~~~~~~
Depending on the `SIM` type you will receive one or more sets of keys which you can use to communicate with the `SIM`
through a secure channel protocol. When using the OTA SMS method, the SCP80 protocol is used and it therefore crucial
to use a keyset that is actually suitable for SCP80.
A keyset usually consists of three keys:
#. KIC: the key used for ciphering (encryption/decryption)
#. KID: the key used to compute a cryptographic checksum (signing)
#. KIK: the key used to encrypt/decrypt key material (key rotation, adding of new keys)
From the transport security perspective, only KIC and KID are relevant. The KIK (also referenced as "Data Encryption
Key", DEK) is only used when keys are rotated or new keys are added (see also ETSI TS 102 226, section 8.2.1.5).
When the keyset is programmed into the security domain of the `SIM`, it is tied to a specific cryptographic algorithm
(3DES, AES128 or AES256) and a so called Key Version Number (KVN). The term "Key Version Number" is misleading, since
it is actually not a version number. It is a unique identifier of a certain keyset which also identifies for which
secure channel protocol the keyset may be used. Keysets with a KVN from 1-15 (``0x01``-``0x0F``) are suitable for SCP80.
This means that it is not only important to know just the KIC/KID/KIK keys. Also the related algorithms and the KVN
numbers must be known.
.. note:: SCP80 keysets typically start counting from 1 upwards. Typical configurations use a set of 3 keysets with
KVN numbers 1-3.
Addressing an Application
~~~~~~~~~~~~~~~~~~~~~~~~~
When communicating with a specific application on a `SIM` via SCP80, it is important to address that application with
the correct parameters. The following two parameters must be known in advance:
#. TAR: The Toolkit Application Reference (TAR) number is a three byte value that uniquely addresses an application
on the `SIM`. The exact values may vary (see also ETSI TS 101 220, Table D.1).
#. MSL: The Minimum Security Level (MSL) is a bit-field that dictates which of the security measures encoded in the
SPI are mandatory (see also ETSI TS 102 225, section 5.1.1).
A practical example
~~~~~~~~~~~~~~~~~~~
.. note:: This tutorial assumes that pySim-smpp2sim is running on the local machine with its default parameters.
See also :ref:`pySim-smpp2sim`.
Let's assume that an OTA SMS shall be sent to the SIM RFM application of an sysmoISIM-SJA2. What we want to do is to
select DF.GSM and to get the select response back.
We have received the following key material from the `SIM` vendor:
::
KIC1: F09C43EE1A0391665CC9F05AF4E0BD10
KID1: 01981F4A20999F62AF99988007BAF6CA
KIK1: 8F8AEE5CDCC5D361368BC45673D99195
KIC2: 01022916E945B656FDE03F806A105FA2
KID2: D326CB69F160333CC5BD1495D448EFD6
KIK2: 08037E0590DFE049D4975FFB8652F625
KIC3: 2B22824D0D27A3A1CEEC512B312082B4
KID3: F1697766925A11F4458295590137B672
KIK3: C7EE69B2C5A1C8E160DD36A38EB517B3
Those are three keysets. The enumeration is directly equal to the KVN used. All three keysets are 3DES keys, which
means triple_des_cbc2 is the correct algorithm to use.
.. note:: The key set configuration can be confirmed by retrieving the key configuration using
`get_data key_information` from within an SCP02 session on ADF.ISD.
In this example we intend to address the SIM RFM application on the `SIM`. Which according to the manual has TAR ``B00010``
and MSL ``0x06``. When we hold ``0x06`` = ``0b00000110`` against the SPI coding chart (see also ETSI TS 102 225,
section 5.1.1). We can deduct that Ciphering and Cryptographic Checksum are mandatory.
.. note:: The MSL (see also ETSI TS 102 226, section 6.1) is assigned to an application by the `SIM` issuer. It is a
custom decision and may vary with different `SIM` types/profiles. In the case of sysmoISIM-SJS1/SJA2/SJA5 the
counter requirement has been waived to simplify lab/research type use. In productive environments, `SIM`
applications should ideally use an MSL that makes the counter mandatory.
In order to select DF.GSM (``0x7F20``) and to retrieve the select response, two APDUs are needed. The first APDU is the
select command ``A0A40000027F20`` and the second is the related get-response command ``A0C0000016``. Those APDUs will be
concatenated and are sent in a single message. The message containing the concatenated APDUs works as a script that
is received by the SIM RFM application and then executed. This method poses some limitations that have to be taken into
account when making requests like this (see also ETSI TS 102 226, section 5).
With this information we may now construct a commandline for `smpp-ota-tool.py`. We will pass the KVN as kid_idx and
kic_idx (see also ETSI TS 102 225, Table 2, fields `KIc` and `KID`). Both index values should refer to the same
keyset/KVN as keysets should not be mixed. (`smpp-ota-tool` still provides separate parameters anyway to allow testing
with invalid keyset combinations)
::
$ PYTHONPATH=./ ./contrib/smpp-ota-tool.py --kic F09C43EE1A0391665CC9F05AF4E0BD10 --kid 01981F4A20999F62AF99988107BAF6CA --kid_idx 1 --kic_idx 1 --algo-crypt triple_des_cbc2 --algo-auth triple_des_cbc2 --tar B00010 --apdu A0A40000027F20 --apdu A0C0000016
2026-02-26 17:13:56 INFO Connecting to localhost:2775...
2026-02-26 17:13:56 INFO C-APDU sending: a0a40000027f20a0c0000016...
2026-02-26 17:13:56 INFO SMS-TPDU sending: 02700000281506191515b00010da1d6cbbd0d11ce4330d844c7408340943e843f67a6d7b0674730881605fd62d...
2026-02-26 17:13:56 INFO SMS-TPDU sent, waiting for response...
2026-02-26 17:13:56 INFO SMS-TPDU received: 027100002c12b000107ddf58d1780f771638b3975759f4296cf5c31efc87a16a1b61921426baa16da1b5ba1a9951d59a39
2026-02-26 17:13:56 INFO SMS-TPDU decoded: (Container(rpl=44, rhl=18, tar=b'\xb0\x00\x10', cntr=b'\x00\x00\x00\x00\x00', pcntr=0, response_status=uEnumIntegerString.new(0, 'por_ok'), cc_rc=b'\x8f\xea\xf5.\xf4\x0e\xc2\x14', secured_data=b'\x02\x90\x00\x00\x00\xff\xff\x7f \x02\x00\x00\x00\x00\x00\t\xb1\x065\x04\x00\x83\x8a\x83\x8a'), Container(number_of_commands=2, last_status_word=u'9000', last_response_data=u'0000ffff7f2002000000000009b106350400838a838a'))
2026-02-26 17:13:56 INFO R-APDU received: 0000ffff7f2002000000000009b106350400838a838a 9000
0000ffff7f2002000000000009b106350400838a838a 9000
2026-02-26 17:13:56 INFO Disconnecting...
The result we see is the select response of DF.GSM and a status word indicating that the last command has been
processed normally.
As we can see, this mechanism now allows us to perform small administrative tasks remotely. We can read the contents of
files remotely or make changes to files. Depending on the changes we make, there may be security issues arising from
replay attacks. With the commandline above, the communication is encrypted and protected by a cryptographic checksum,
so an adversary can neither read, nor alter the message. However, an adversary could still replay an intercepted
message and the `SIM` would happily execute the contained APDUs again.
To prevent this, we may include a replay protection counter within the message. In this case, the MSL indicates that a
replay protection counter is not required. However, to extended the security of our messages, we may chose to use a
counter anyway. In the following example, we will encode a counter value of 100. We will instruct the `SIM` to make sure
that the value we send is higher than the counter value that is currently stored in the `SIM`.
To add a replay connection counter we add the commandline arguments `--cntr-req` to set the counter requirement and
`--cntr` to pass the counter value.
::
$ PYTHONPATH=./ ./contrib/smpp-ota-tool.py --kic F09C43EE1A0391665CC9F05AF4E0BD10 --kid 01981F4A20999F62AF99988107BAF6CA --kid_idx 1 --kic_idx 1 --algo-crypt triple_des_cbc2 --algo-auth triple_des_cbc2 --tar B00010 --apdu A0A40000027F20 --apdu A0C0000016 --cntr-req counter_must_be_higher --cntr 100
2026-02-26 17:16:39 INFO Connecting to localhost:2775...
2026-02-26 17:16:39 INFO C-APDU sending: a0a40000027f20a0c0000016...
2026-02-26 17:16:39 INFO SMS-TPDU sending: 02700000281516191515b000103a4f599e94f2b5dcfbbda984761b7977df6514c57a580fb4844787c436d2eade...
2026-02-26 17:16:39 INFO SMS-TPDU sent, waiting for response...
2026-02-26 17:16:39 INFO SMS-TPDU received: 027100002c12b0001049fb0315f6c6401b553867f412cefaf9355b38271178edb342a3bc9cc7e670cdc1f45eea6ffcbb39
2026-02-26 17:16:39 INFO SMS-TPDU decoded: (Container(rpl=44, rhl=18, tar=b'\xb0\x00\x10', cntr=b'\x00\x00\x00\x00d', pcntr=0, response_status=uEnumIntegerString.new(0, 'por_ok'), cc_rc=b'\xa9/\xc7\xc9\x00"\xab5', secured_data=b'\x02\x90\x00\x00\x00\xff\xff\x7f \x02\x00\x00\x00\x00\x00\t\xb1\x065\x04\x00\x83\x8a\x83\x8a'), Container(number_of_commands=2, last_status_word=u'9000', last_response_data=u'0000ffff7f2002000000000009b106350400838a838a'))
2026-02-26 17:16:39 INFO R-APDU received: 0000ffff7f2002000000000009b106350400838a838a 9000
0000ffff7f2002000000000009b106350400838a838a 9000
2026-02-26 17:16:39 INFO Disconnecting...
The `SIM` has accepted the message. The message got processed and the `SIM` has set its internal to 100. As an experiment,
we may try to re-use the counter value:
::
$ PYTHONPATH=./ ./contrib/smpp-ota-tool.py --kic F09C43EE1A0391665CC9F05AF4E0BD10 --kid 01981F4A20999F62AF99988107BAF6CA --kid_idx 1 --kic_idx 1 --algo-crypt triple_des_cbc2 --algo-auth triple_des_cbc2 --tar B00010 --apdu A0A40000027F20 --apdu A0C0000016 --cntr-req counter_must_be_higher --cntr 100
2026-02-26 17:16:43 INFO Connecting to localhost:2775...
2026-02-26 17:16:43 INFO C-APDU sending: a0a40000027f20a0c0000016...
2026-02-26 17:16:43 INFO SMS-TPDU sending: 02700000281516191515b000103a4f599e94f2b5dcfbbda984761b7977df6514c57a580fb4844787c436d2eade...
2026-02-26 17:16:43 INFO SMS-TPDU sent, waiting for response...
2026-02-26 17:16:43 INFO SMS-TPDU received: 027100000b0ab0001000000000000006
2026-02-26 17:16:43 INFO SMS-TPDU decoded: (Container(rpl=11, rhl=10, tar=b'\xb0\x00\x10', cntr=b'\x00\x00\x00\x00\x00', pcntr=0, response_status=uEnumIntegerString.new(6, 'undefined_security_error'), cc_rc=b'', secured_data=b''), None)
Traceback (most recent call last):
File "/home/user/work/git_master/pysim/./contrib/smpp-ota-tool.py", line 238, in <module>
resp, sw = smpp_handler.transceive_apdu(apdu, opts.src_addr, opts.dest_addr, opts.timeout)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/user/work/git_master/pysim/./contrib/smpp-ota-tool.py", line 162, in transceive_apdu
raise ValueError("Response does not contain any last_response_data, no R-APDU received!")
ValueError: Response does not contain any last_response_data, no R-APDU received!
2026-02-26 17:16:43 INFO Disconnecting...
As we can see, the `SIM` has rejected the message with an `undefined_security_error`. The replay-protection-counter
ensures that a message can only be sent once.
.. note:: The replay-protection-counter is implemented as a 5 byte integer value (see also ETSI TS 102 225, Table 3).
When the counter has reached its maximum, it will not overflow nor can it be reset.
smpp-ota-tool syntax
~~~~~~~~~~~~~~~~~~~~
.. argparse::
:module: contrib.smpp-ota-tool
:func: option_parser
:prog: contrib/smpp-ota-tool.py

View File

@@ -55,3 +55,5 @@ And once your external program is sending SMS to the simulated SMSC, it will log
SMSPPDownload(DeviceIdentities({'source_dev_id': 'network', 'dest_dev_id': 'uicc'}),Address({'ton_npi': 0, 'call_number': '0123456'}),SMS_TPDU({'tpdu': '400290217ff6227052000000002d02700000281516191212b0000127fa28a5bac69d3c5e9df2c7155dfdde449c826b236215566530787b30e8be5d'}))
INFO root: ENVELOPE: d147820283818604001032548b3b400290217ff6227052000000002d02700000281516191212b0000127fa28a5bac69d3c5e9df2c7155dfdde449c826b236215566530787b30e8be5d
INFO root: SW 9000: 027100002412b000019a551bb7c28183652de0ace6170d0e563c5e949a3ba56747fe4c1dbbef16642c
.. note:: for sending OTA SMS messages :ref:`smpp-ota-tool` may be used.

View File

@@ -44,6 +44,11 @@ from pySim.legacy.ts_51_011 import EF
from pySim.card_handler import *
from pySim.utils import *
from pathlib import Path
import logging
from pySim.log import PySimLogger
log = PySimLogger.get(Path(__file__).stem)
def parse_options():
@@ -185,6 +190,7 @@ def parse_options():
default=False, action="store_true")
parser.add_argument("--card_handler", dest="card_handler_config", metavar="FILE",
help="Use automatic card handling machine")
parser.add_argument("--verbose", help="Enable verbose logging", action='store_true', default=False)
options = parser.parse_args()
@@ -770,6 +776,9 @@ if __name__ == '__main__':
# Parse options
opts = parse_options()
# Setup logger
PySimLogger.setup(print, {logging.WARN: "\033[33m"}, opts.verbose)
# Init card reader driver
sl = init_reader(opts)

View File

@@ -25,7 +25,6 @@
import hashlib
import argparse
import os
import random
import re
import sys
@@ -44,12 +43,19 @@ from pySim.exceptions import SwMatchError
from pySim.legacy.cards import card_detect, SimCard, UsimCard, IsimCard
from pySim.utils import dec_imsi, dec_iccid
from pySim.legacy.utils import format_xplmn_w_act, dec_st, dec_msisdn
from pySim.ts_51_011 import EF_SMSP
from pathlib import Path
import logging
from pySim.log import PySimLogger
log = PySimLogger.get(Path(__file__).stem)
option_parser = argparse.ArgumentParser(description='Legacy tool for reading some parts of a SIM card',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
option_parser.add_argument("--verbose", help="Enable verbose logging", action='store_true', default=False)
argparse_add_reader_args(option_parser)
def select_app(adf: str, card: SimCard):
"""Select application by its AID"""
sw = 0
@@ -74,6 +80,9 @@ if __name__ == '__main__':
# Parse options
opts = option_parser.parse_args()
# Setup logger
PySimLogger.setup(print, {logging.WARN: "\033[33m"}, opts.verbose)
# Init card reader driver
sl = init_reader(opts)
@@ -141,6 +150,15 @@ if __name__ == '__main__':
(res, sw) = card.read_record('SMSP', 1)
if sw == '9000':
print("SMSP: %s" % (res,))
ef_smsp = EF_SMSP()
smsc_a = ef_smsp.decode_record_bin(h2b(res), 1).get('tp_sc_addr', {})
smsc_n = smsc_a.get('call_number', None)
if smsc_a.get('ton_npi', {}).get('type_of_number', None) == 'international' and smsc_n is not None:
smsc = '+' + smsc_n
else:
smsc = smsc_n
if smsc is not None:
print("SMSC: %s" % (smsc,))
else:
print("SMSP: Can't read, response code = %s" % (sw,))

View File

@@ -69,12 +69,12 @@ from pySim.ts_102_222 import Ts102222Commands
from pySim.gsm_r import DF_EIRENE
from pySim.cat import ProactiveCommand
from pySim.card_key_provider import CardKeyProviderCsv
from pySim.card_key_provider import CardKeyProviderCsv, CardKeyProviderPgsql
from pySim.card_key_provider import card_key_provider_register, card_key_provider_get_field, card_key_provider_get
from pySim.app import init_card
log = PySimLogger.get("main")
log = PySimLogger.get(Path(__file__).stem)
class Cmd2Compat(cmd2.Cmd):
"""Backwards-compatibility wrapper around cmd2.Cmd to support older and newer
@@ -107,12 +107,12 @@ Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/
kwargs = {'include_ipy': True}
self.verbose = verbose
self._onchange_verbose('verbose', False, self.verbose);
PySimLogger.setup(self.poutput, {logging.WARN: YELLOW})
self._onchange_verbose('verbose', False, self.verbose)
# pylint: disable=unexpected-keyword-arg
super().__init__(persistent_history_file='~/.pysim_shell_history', allow_cli_args=False,
auto_load_commands=False, startup_script=script, **kwargs)
PySimLogger.setup(self.poutput, {logging.WARN: YELLOW})
self.intro = style(self.BANNER, fg=RED)
self.default_category = 'pySim-shell built-in commands'
self.card = None
@@ -136,8 +136,7 @@ Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/
self.add_settable(Settable2Compat('apdu_trace', bool, 'Trace and display APDUs exchanged with card', self,
onchange_cb=self._onchange_apdu_trace))
self.add_settable(Settable2Compat('apdu_strict', bool,
'Enforce APDU responses according to ISO/IEC 7816-3, table 12', self,
onchange_cb=self._onchange_apdu_strict))
'Strictly apply APDU format according to ISO/IEC 7816-3, table 12', self))
self.add_settable(Settable2Compat('verbose', bool,
'Enable/disable verbose logging', self,
onchange_cb=self._onchange_verbose))
@@ -218,13 +217,6 @@ Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/
else:
self.card._scc._tp.apdu_tracer = None
def _onchange_apdu_strict(self, param_name, old, new):
if self.card:
if new == True:
self.card._scc._tp.apdu_strict = True
else:
self.card._scc._tp.apdu_strict = False
def _onchange_verbose(self, param_name, old, new):
PySimLogger.set_verbose(new)
if new == True:
@@ -281,7 +273,7 @@ Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/
apdu_cmd_parser.add_argument('--expect-sw', help='expect a specified status word', type=str, default=None)
apdu_cmd_parser.add_argument('--expect-response-regex', help='match response against regex', type=str, default=None)
apdu_cmd_parser.add_argument('--raw', help='Bypass the logical channel (and secure channel)', action='store_true')
apdu_cmd_parser.add_argument('APDU', type=is_hexstr, help='APDU as hex string')
apdu_cmd_parser.add_argument('APDU', type=is_hexstr, help='APDU as hex string (see also: ISO/IEC 7816-3, section 12.1')
@cmd2.with_argparser(apdu_cmd_parser)
def do_apdu(self, opts):
@@ -290,14 +282,23 @@ Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/
tracked. Depending on the raw APDU sent, pySim-shell may not continue to work as expected if you e.g. select
a different file."""
if not hasattr(self, 'apdu_strict_warning_displayed') and self.apdu_strict is False:
self.poutput("Warning: The default for the setable parameter `apdu_strict` will be changed from")
self.poutput(" `False` to `True` in future pySim-shell releases. In case you are using")
self.poutput(" the `apdu` command from a script that still mixes APDUs with TPDUs, consider")
self.poutput(" fixing or adding a `set apdu_strict false` line at the beginning.")
self.apdu_strict_warning_displayed = True;
# When sending raw APDUs we access the scc object through _scc member of the card object. It should also be
# noted that the apdu command plays an exceptional role since it is the only card accessing command that
# can be executed without the presence of a runtime state (self.rs) object. However, this also means that
# self.lchan is also not present (see method equip).
self.card._scc._tp.apdu_strict = self.apdu_strict
if opts.raw or self.lchan is None:
data, sw = self.card._scc.send_apdu(opts.APDU, apply_lchan = False)
else:
data, sw = self.lchan.scc.send_apdu(opts.APDU, apply_lchan = False)
self.card._scc._tp.apdu_strict = True
if data:
self.poutput("SW: %s, RESP: %s" % (sw, data))
else:
@@ -519,8 +520,17 @@ Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/
@cmd2.with_category(CUSTOM_CATEGORY)
def do_version(self, opts):
"""Print the pySim software version."""
import pkg_resources
self.poutput(pkg_resources.get_distribution('pySim'))
from importlib.metadata import version as vsn
self.poutput("pyosmocom " + vsn('pyosmocom'))
import os
cwd = os.path.dirname(os.path.realpath(__file__))
if os.path.isdir(os.path.join(cwd, ".git")):
import subprocess
url = subprocess.check_output(['git', 'config', '--get', 'remote.origin.url']).decode('ascii').strip()
version = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=cwd).decode('ascii').strip()
self.poutput(os.path.basename(url) + " " + version)
else:
self.poutput("pySim " + vsn('pySim'))
@with_default_category('pySim Commands')
class PySimCommands(CommandSet):
@@ -1138,8 +1148,11 @@ global_group.add_argument("--verbose", help="Enable verbose logging",
card_key_group = option_parser.add_argument_group('Card Key Provider Options')
card_key_group.add_argument('--csv', metavar='FILE',
default=str(Path.home()) + "/.osmocom/pysim/card_data.csv",
default="~/.osmocom/pysim/card_data.csv",
help='Read card data from CSV file')
card_key_group.add_argument('--pgsql', metavar='FILE',
default="~/.osmocom/pysim/card_data_pgsql.cfg",
help='Read card data from PostgreSQL database (config file)')
card_key_group.add_argument('--csv-column-key', metavar='FIELD:AES_KEY_HEX', default=[], action='append',
help=argparse.SUPPRESS, dest='column_key')
card_key_group.add_argument('--column-key', metavar='FIELD:AES_KEY_HEX', default=[], action='append',
@@ -1163,13 +1176,7 @@ if __name__ == '__main__':
opts = option_parser.parse_args()
# Ensure that we are able to print formatted warnings from the beginning.
PySimLogger.setup(print, {logging.WARN: YELLOW})
if (opts.verbose):
PySimLogger.set_verbose(True)
PySimLogger.set_level(logging.DEBUG)
else:
PySimLogger.set_verbose(False)
PySimLogger.set_level(logging.INFO)
PySimLogger.setup(print, {logging.WARN: YELLOW}, opts.verbose)
# Register csv-file as card data provider, either from specified CSV
# or from CSV file in home directory
@@ -1177,8 +1184,10 @@ if __name__ == '__main__':
for par in opts.column_key:
name, key = par.split(':')
column_keys[name] = key
if os.path.isfile(opts.csv):
card_key_provider_register(CardKeyProviderCsv(opts.csv, column_keys))
if os.path.isfile(os.path.expanduser(opts.csv)):
card_key_provider_register(CardKeyProviderCsv(os.path.expanduser(opts.csv), column_keys))
if os.path.isfile(os.path.expanduser(opts.pgsql)):
card_key_provider_register(CardKeyProviderPgsql(os.path.expanduser(opts.pgsql), column_keys))
# Init card reader driver
sl = init_reader(opts, proactive_handler = Proact())

View File

@@ -23,6 +23,7 @@ from pySim.apdu_source.gsmtap import GsmtapApduSource
from pySim.apdu_source.pyshark_rspro import PysharkRsproPcap, PysharkRsproLive
from pySim.apdu_source.pyshark_gsmtap import PysharkGsmtapPcap
from pySim.apdu_source.tca_loader_log import TcaLoaderLogApduSource
from pySim.apdu_source.stdin_hex import StdinHexApduSource
from pySim.apdu.ts_102_221 import UiccSelect, UiccStatus
@@ -190,6 +191,10 @@ parser_tcaloader_log = subparsers.add_parser('tca-loader-log', help="""
parser_tcaloader_log.add_argument('-f', '--log-file', required=True,
help='Name of the log file to be read')
parser_stdin_hex = subparsers.add_parser('stdin-hex', help="""
Read APDUs as hex-string from stdin.""")
if __name__ == '__main__':
opts = option_parser.parse_args()
@@ -205,6 +210,8 @@ if __name__ == '__main__':
s = PysharkGsmtapPcap(opts.pcap_file)
elif opts.source == 'tca-loader-log':
s = TcaLoaderLogApduSource(opts.log_file)
elif opts.source == 'stdin-hex':
s = StdinHexApduSource()
else:
raise ValueError("unsupported source %s", opts.source)

View File

@@ -84,5 +84,5 @@ class PysharkGsmtapPcap(_PysharkGsmtap):
Args:
pcap_filename: File name of the pcap file to be opened
"""
pyshark_inst = pyshark.FileCapture(pcap_filename, display_filter='gsm_sim', use_json=True, keep_packets=False)
pyshark_inst = pyshark.FileCapture(pcap_filename, display_filter='gsm_sim || iso7816.atr', use_json=True, keep_packets=False)
super().__init__(pyshark_inst)

View File

@@ -0,0 +1,39 @@
# coding=utf-8
# (C) 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 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 <http://www.gnu.org/licenses/>.
from pySim.utils import h2b
from pySim.apdu.ts_102_221 import ApduCommands as UiccApduCommands
from pySim.apdu.ts_102_222 import ApduCommands as UiccAdmApduCommands
from pySim.apdu.ts_31_102 import ApduCommands as UsimApduCommands
from pySim.apdu.global_platform import ApduCommands as GpApduCommands
from . import ApduSource, PacketType, CardReset
ApduCommands = UiccApduCommands + UiccAdmApduCommands + UsimApduCommands + GpApduCommands
class StdinHexApduSource(ApduSource):
"""ApduSource for reading apdu hex-strings from stdin."""
def read_packet(self) -> PacketType:
while True:
command = input("C-APDU >")
if len(command) == 0:
continue
response = '9000'
return ApduCommands.parse_cmd_bytes(h2b(command) + h2b(response))

View File

@@ -72,10 +72,10 @@ class ApduArDO(BER_TLV_IE, tag=0xd0):
if do[0] == 0x01:
self.decoded = {'generic_access_rule': 'always'}
return self.decoded
return ValueError('Invalid 1-byte generic APDU access rule')
raise ValueError('Invalid 1-byte generic APDU access rule')
else:
if len(do) % 8:
return ValueError('Invalid non-modulo-8 length of APDU filter: %d' % len(do))
raise ValueError('Invalid non-modulo-8 length of APDU filter: %d' % len(do))
self.decoded = {'apdu_filter': []}
offset = 0
while offset < len(do):
@@ -90,19 +90,19 @@ class ApduArDO(BER_TLV_IE, tag=0xd0):
return b'\x00'
if self.decoded['generic_access_rule'] == 'always':
return b'\x01'
return ValueError('Invalid 1-byte generic APDU access rule')
raise ValueError('Invalid 1-byte generic APDU access rule')
else:
if not 'apdu_filter' in self.decoded:
return ValueError('Invalid APDU AR DO')
raise ValueError('Invalid APDU AR DO')
filters = self.decoded['apdu_filter']
res = b''
for f in filters:
if not 'header' in f or not 'mask' in f:
return ValueError('APDU filter must contain header and mask')
raise ValueError('APDU filter must contain header and mask')
header_b = h2b(f['header'])
mask_b = h2b(f['mask'])
if len(header_b) != 4 or len(mask_b) != 4:
return ValueError('APDU filter header and mask must each be 4 bytes')
raise ValueError('APDU filter header and mask must each be 4 bytes')
res += header_b + mask_b
return res
@@ -269,7 +269,7 @@ class ADF_ARAM(CardADF):
cmd_do_enc = cmd_do.to_ie()
cmd_do_len = len(cmd_do_enc)
if cmd_do_len > 255:
return ValueError('DO > 255 bytes not supported yet')
raise ValueError('DO > 255 bytes not supported yet')
else:
cmd_do_enc = b''
cmd_do_len = 0
@@ -361,7 +361,7 @@ class ADF_ARAM(CardADF):
ar_do_content += [{'apdu_ar_do': {'generic_access_rule': 'always'}}]
elif opts.apdu_filter:
if len(opts.apdu_filter) % 16:
return ValueError('Invalid non-modulo-16 length of APDU filter: %d' % len(do))
raise ValueError(f'Invalid non-modulo-16 length of APDU filter: {len(opts.apdu_filter)}')
offset = 0
apdu_filter = []
while offset < len(opts.apdu_filter):

View File

@@ -7,7 +7,7 @@ there are also automatic card feeders.
"""
#
# (C) 2019 by Sysmocom s.f.m.c. GmbH
# (C) 2019 by sysmocom - s.f.m.c. GmbH
# All Rights Reserved
#
# This program is free software: you can redistribute it and/or modify

View File

@@ -10,7 +10,7 @@ the need of manually entering the related card-individual data on every
operation with pySim-shell.
"""
# (C) 2021-2025 by Sysmocom s.f.m.c. GmbH
# (C) 2021-2025 by sysmocom - s.f.m.c. GmbH
# All Rights Reserved
#
# Author: Philipp Maier, Harald Welte
@@ -36,8 +36,9 @@ from pySim.log import PySimLogger
import abc
import csv
import logging
import yaml
log = PySimLogger.get("CARDKEY")
log = PySimLogger.get(__name__)
card_key_providers = [] # type: List['CardKeyProvider']
@@ -57,7 +58,7 @@ class CardKeyFieldCryptor:
'UICC_SCP02': ['UICC_SCP02_KIC1', 'UICC_SCP02_KID1', 'UICC_SCP02_KIK1'],
'UICC_SCP03': ['UICC_SCP03_KIC1', 'UICC_SCP03_KID1', 'UICC_SCP03_KIK1'],
'SCP03_ISDR': ['SCP03_ENC_ISDR', 'SCP03_MAC_ISDR', 'SCP03_DEK_ISDR'],
'SCP03_ISDA': ['SCP03_ENC_ISDR', 'SCP03_MAC_ISDA', 'SCP03_DEK_ISDA'],
'SCP03_ISDA': ['SCP03_ENC_ISDA', 'SCP03_MAC_ISDA', 'SCP03_DEK_ISDA'],
'SCP03_ECASD': ['SCP03_ENC_ECASD', 'SCP03_MAC_ECASD', 'SCP03_DEK_ECASD'],
}
@@ -159,6 +160,7 @@ class CardKeyProviderCsv(CardKeyProvider):
csv_filename : file name (path) of CSV file containing card-individual key/data
transport_keys : (see class CardKeyFieldCryptor)
"""
log.info("Using CSV file as card key data source: %s" % csv_filename)
self.csv_file = open(csv_filename, 'r')
if not self.csv_file:
raise RuntimeError("Could not open CSV file '%s'" % csv_filename)
@@ -186,6 +188,69 @@ class CardKeyProviderCsv(CardKeyProvider):
return None
return return_dict
class CardKeyProviderPgsql(CardKeyProvider):
"""Card key provider implementation that allows to query against a specified PostgreSQL database table."""
def __init__(self, config_filename: str, transport_keys: dict):
"""
Args:
config_filename : file name (path) of CSV file containing card-individual key/data
transport_keys : (see class CardKeyFieldCryptor)
"""
import psycopg2
log.info("Using SQL database as card key data source: %s" % config_filename)
with open(config_filename, "r") as cfg:
config = yaml.load(cfg, Loader=yaml.FullLoader)
log.info("Card key database name: %s" % config.get('db_name'))
db_users = config.get('db_users')
user = db_users.get('reader')
if user is None:
raise ValueError("user for role 'reader' not set up in config file.")
self.conn = psycopg2.connect(dbname=config.get('db_name'),
user=user.get('name'),
password=user.get('pass'),
host=config.get('host'))
self.tables = config.get('table_names')
log.info("Card key database tables: %s" % str(self.tables))
self.crypt = CardKeyFieldCryptor(transport_keys)
def get(self, fields: List[str], key: str, value: str) -> Dict[str, str]:
import psycopg2
from psycopg2.sql import Identifier, SQL
db_result = None
for t in self.tables:
self.conn.rollback()
cur = self.conn.cursor()
# Make sure that the database table and the key column actually exists. If not, move on to the next table
cur.execute("SELECT column_name FROM information_schema.columns where table_name = %s;", (t,))
cols_result = cur.fetchall()
if cols_result == []:
log.warning("Card Key database seems to lack table %s, check config file!" % t)
continue
if (key.lower(),) not in cols_result:
continue
# Query requested columns from database table
query = SQL("SELECT {}").format(Identifier(fields[0].lower()))
for f in fields[1:]:
query += SQL(", {}").format(Identifier(f.lower()))
query += SQL(" FROM {} WHERE {} = %s LIMIT 1;").format(Identifier(t.lower()),
Identifier(key.lower()))
cur.execute(query, (value,))
db_result = cur.fetchone()
cur.close()
if db_result:
break
if db_result is None:
return None
result = dict(zip(fields, db_result))
for k in result.keys():
result[k] = self.crypt.decrypt_field(k, result.get(k))
return result
def card_key_provider_register(provider: CardKeyProvider, provider_list=card_key_providers):

View File

@@ -128,10 +128,10 @@ class EF_AD(TransparentEF):
cell_test = 0x04
def __init__(self, fid='6f43', sfid=None, name='EF.AD',
desc='Service Provider Name', size=(3, None), **kwargs):
desc='Administrative Data', size=(3, None), **kwargs):
super().__init__(fid, sfid=sfid, name=name, desc=desc, size=size, **kwargs)
self._construct = Struct(
# Byte 1: Display Condition
# Byte 1: MS operation mode
'ms_operation_mode'/Enum(Byte, self.OP_MODE),
# Bytes 2-3: Additional information
'additional_info'/Bytes(2),

View File

@@ -54,6 +54,8 @@ def compile_asn1_subdir(subdir_name:str, codec='der'):
__ver = sys.version_info
if (__ver.major, __ver.minor) >= (3, 9):
for i in resources.files('pySim.esim').joinpath('asn1').joinpath(subdir_name).iterdir():
if not i.name.endswith('.asn'):
continue
asn_txt += i.read_text()
asn_txt += "\n"
#else:

View File

@@ -27,7 +27,7 @@ logger.setLevel(logging.DEBUG)
class param:
class Iccid(ApiParamString):
"""String representation of 19 or 20 digits, where the 20th digit MAY optionally be the padding
"""String representation of 18 to 20 digits, where the 20th digit MAY optionally be the padding
character F."""
@classmethod
def _encode(cls, data):
@@ -40,7 +40,7 @@ class param:
@classmethod
def verify_encoded(cls, data):
if len(data) not in [19, 20]:
if len(data) not in (18, 19, 20):
raise ValueError('ICCID (%s) length (%u) invalid' % (data, len(data)))
@classmethod
@@ -53,7 +53,7 @@ class param:
@classmethod
def verify_decoded(cls, data):
data = str(data)
if len(data) not in [19, 20]:
if len(data) not in (18, 19, 20):
raise ValueError('ICCID (%s) length (%u) invalid' % (data, len(data)))
if len(data) == 19:
decimal_part = data

View File

@@ -149,7 +149,8 @@ class ApiError(Exception):
'message': None,
}
actual_sec = func_ex_status.get('statusCodeData', None)
sec.update(actual_sec)
if actual_sec:
sec.update(actual_sec)
self.subject_code = sec['subjectCode']
self.reason_code = sec['reasonCode']
self.subject_id = sec['subjectIdentifier']
@@ -255,5 +256,10 @@ class JsonHttpApiFunction(abc.ABC):
raise HttpHeaderError(response)
if response.content:
return self.decode(response.json())
if response.headers.get('Content-Type').startswith('application/json'):
return self.decode(response.json())
elif response.headers.get('Content-Type').startswith('text/plain;charset=UTF-8'):
return { 'data': response.content.decode('utf-8') }
raise HttpHeaderError(f'unimplemented response Content-Type: {response.headers=!r}')
return None

View File

@@ -21,6 +21,8 @@ import io
import os
from typing import Tuple, List, Optional, Dict, Union
from collections import OrderedDict
from difflib import SequenceMatcher, Match
import asn1tools
import zipfile
from pySim import javacard
@@ -44,6 +46,29 @@ asn1 = compile_asn1_subdir('saip')
logger = logging.getLogger(__name__)
class NonMatch(Match):
"""Representing a contiguous non-matching block of data; the opposite of difflib.Match"""
@classmethod
def from_matchlist(cls, l: List[Match], size:int) -> List['NonMatch']:
"""Build a list of non-matching blocks of data from its inverse (list of matching blocks).
The caller must ensure that the input list is ordered, non-overlapping and only contains
matches at equal offsets in a and b."""
res = []
cur = 0
for match in l:
if match.a != match.b:
raise ValueError('only works for equal-offset matches')
assert match.a >= cur
nm_len = match.a - cur
if nm_len > 0:
# there's no point in generating zero-lenth non-matching sections
res.append(cls(a=cur, b=cur, size=nm_len))
cur = match.a + match.size
if size > cur:
res.append(cls(a=cur, b=cur, size=size-cur))
return res
class Naa:
"""A class defining a Network Access Application (NAA)"""
name = None
@@ -126,6 +151,8 @@ class File:
self.df_name = None
self.fill_pattern = None
self.fill_pattern_repeat = False
self.pstdo = None # pinStatusTemplateDO, mandatory for DF/ADF
self.lcsi = None # optional life cycle status indicator
# apply some defaults from profile
if self.template:
self.from_template(self.template)
@@ -144,6 +171,9 @@ class File:
def file_size(self) -> Optional[int]:
"""Return the size of the file in bytes."""
if self.file_type in ['LF', 'CY']:
if self._file_size and self.nb_rec is None and self.rec_len:
self.nb_rec = self._file_size // self.rec_len
return self.nb_rec * self.rec_len
elif self.file_type in ['TR', 'BT']:
return self._file_size
@@ -183,7 +213,7 @@ class File:
self.file_type = template.file_type
self.fid = template.fid
self.sfi = template.sfi
self.arr = template.arr.to_bytes(1)
self.arr = template.arr.to_bytes(1, 'big')
if hasattr(template, 'rec_len'):
self.rec_len = template.rec_len
else:
@@ -227,7 +257,7 @@ class File:
fileDescriptor['shortEFID'] = bytes([self.sfi])
if self.df_name:
fileDescriptor['dfName'] = self.df_name
if self.arr and self.arr != self.template.arr.to_bytes(1):
if self.arr and self.arr != self.template.arr.to_bytes(1, 'big'):
fileDescriptor['securityAttributesReferenced'] = self.arr
if self.file_type in ['LF', 'CY']:
fdb_dec['file_type'] = 'working_ef'
@@ -250,6 +280,8 @@ class File:
elif self.file_type in ['MF', 'DF', 'ADF']:
fdb_dec['file_type'] = 'df'
fdb_dec['structure'] = 'no_info_given'
# pinStatusTemplateDO is mandatory for DF/ADF
fileDescriptor['pinStatusTemplateDO'] = self.pstdo
# build file descriptor based on above input data
fd_dict = {}
if len(fdb_dec):
@@ -264,7 +296,7 @@ class File:
if self.read_and_update_when_deact:
spfi |= 0x40 # TS 102 222 Table 5
if spfi != 0x00:
pefi['specialFileInformation'] = spfi.to_bytes(1)
pefi['specialFileInformation'] = spfi.to_bytes(1, 'big')
if self.fill_pattern:
if not self.fill_pattern_repeat:
pefi['fillPattern'] = self.fill_pattern
@@ -276,6 +308,8 @@ class File:
# desired fill or repeat pattern in the "proprietaryEFInfo" element for the EF in Profiles
# downloaded to a V2.2 or earlier eUICC.
fileDescriptor['proprietaryEFInfo'] = pefi
if self.lcsi:
fileDescriptor['lcsi'] = self.lcsi
logger.debug("%s: to_fileDescriptor(%s)" % (self, fileDescriptor))
return fileDescriptor
@@ -291,6 +325,12 @@ class File:
dfName = fileDescriptor.get('dfName', None)
if dfName:
self.df_name = dfName
efFileSize = fileDescriptor.get('efFileSize', None)
if efFileSize:
self._file_size = self._decode_file_size(efFileSize)
self.pstdo = fileDescriptor.get('pinStatusTemplateDO', None)
self.lcsi = fileDescriptor.get('lcsi', None)
pefi = fileDescriptor.get('proprietaryEFInfo', {})
securityAttributesReferenced = fileDescriptor.get('securityAttributesReferenced', None)
if securityAttributesReferenced:
@@ -300,13 +340,11 @@ class File:
fdb_dec = fd_dec['file_descriptor_byte']
self.shareable = fdb_dec['shareable']
if fdb_dec['file_type'] == 'working_ef':
efFileSize = fileDescriptor.get('efFileSize', None)
if fd_dec['num_of_rec']:
self.nb_rec = fd_dec['num_of_rec']
if fd_dec['record_len']:
self.rec_len = fd_dec['record_len']
if efFileSize:
self._file_size = self._decode_file_size(efFileSize)
if self.rec_len and self.nb_rec == None:
# compute the number of records from file size and record length
self.nb_rec = self._file_size // self.rec_len
@@ -403,15 +441,43 @@ class File:
elif k == 'fillFileContent':
stream.write(v)
else:
return ValueError("Unknown key '%s' in tuple list" % k)
raise ValueError("Unknown key '%s' in tuple list" % k)
return stream.getvalue()
def file_content_to_tuples(self) -> List[Tuple]:
# FIXME: simplistic approach. needs optimization. We should first check if the content
# matches the expanded default value from the template. If it does, return empty list.
# Next, we should compute the diff between the default value and self.body, and encode
# that as a sequence of fillFileOffset and fillFileContent tuples.
return [('fillFileContent', self.body)]
def file_content_to_tuples(self, optimize:bool = False) -> List[Tuple]:
"""Encode the file contents into a list of fillFileContent / fillFileOffset tuples that can be fed
into the asn.1 encoder. If optimize is True, it will try to encode only the differences from the
fillFileContent of the profile template. Otherwise, the entire file contents will be encoded
as-is."""
if not self.file_type in ['TR', 'LF', 'CY', 'BT']:
return []
if not optimize:
# simplistic approach: encode the full file, ignoring the template/default
return [('fillFileContent', self.body)]
# Try to 'compress' the file body, based on the default file contents.
if self.template:
default = self.template.expand_default_value_pattern(length=len(self.body))
if not default:
sm = SequenceMatcher(a=b'\xff'*len(self.body), b=self.body)
else:
if default == self.body:
# 100% match: return an empty tuple list to make eUICC use the default
return []
sm = SequenceMatcher(a=default, b=self.body)
else:
# no template at all: we can only remove padding
sm = SequenceMatcher(a=b'\xff'*len(self.body), b=self.body)
matching_blocks = sm.get_matching_blocks()
# we can only make use of matches that have the same offset in 'a' and 'b'
matching_blocks = [x for x in matching_blocks if x.size > 0 and x.a == x.b]
non_matching_blocks = NonMatch.from_matchlist(matching_blocks, self.file_size)
ret = []
cur = 0
for block in non_matching_blocks:
ret.append(('fillFileOffset', block.a - cur))
ret.append(('fillFileContent', self.body[block.a:block.a+block.size]))
cur += block.size
return ret
def __str__(self) -> str:
return "File(%s)" % self.pe_name
@@ -633,8 +699,15 @@ class FsProfileElement(ProfileElement):
self.pe_sequence.cur_df = pe_df
self.pe_sequence.cur_df = self.pe_sequence.cur_df.add_file(file)
def file2pe(self, file: File):
"""Update the "decoded" member for the given file with the contents from the given File instance.
We expect that the File instance is part of self.files"""
if self.files[file.pe_name] != file:
raise ValueError("The file you passed is not part of this ProfileElement")
self.decoded[file.pe_name] = file.to_tuples()
def files2pe(self):
"""Update the "decoded" member with the contents of the "files" member."""
"""Update the "decoded" member for each file with the contents of the "files" member."""
for k, f in self.files.items():
self.decoded[k] = f.to_tuples()
@@ -985,9 +1058,9 @@ class SecurityDomainKey:
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,
return 'SdKey(KVN=0x%02x, ID=0x%02x, Usage=0x%x, Comp=%s)' % (self.key_version_number,
self.key_identifier,
self.key_usage_qualifier,
build_construct(KeyUsageQualifier, self.key_usage_qualifier)[0],
repr(self.key_components))
@classmethod
@@ -1006,6 +1079,13 @@ class SecurityDomainKey:
'keyVersionNumber': bytes([self.key_version_number]),
'keyComponents': [k.to_saip_dict() for k in self.key_components]}
def get_key_component(self, key_type):
for kc in self.key_components:
if kc.key_type == key_type:
return kc.key_data
return None
class ProfileElementSD(ProfileElement):
"""Class representing a securityDomain ProfileElement."""
type = 'securityDomain'
@@ -1020,6 +1100,7 @@ class ProfileElementSD(ProfileElement):
def __init__(self, decoded: Optional[dict] = None, **kwargs):
super().__init__(decoded, **kwargs)
if decoded:
self._post_decode()
return
# provide some reasonable defaults for a MNO-SD
self.decoded['instance'] = {
@@ -1738,8 +1819,7 @@ class ProfileElementSequence:
del hdr.decoded['eUICC-Mandatory-services'][service]
# remove any associated 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)]
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:
@@ -1782,7 +1862,7 @@ class ProfileElementSequence:
return None
@staticmethod
def peclass_for_path(path: Path) -> Optional[ProfileElement]:
def peclass_for_path(path: Path) -> Tuple[Optional[ProfileElement], Optional[templates.FileTemplate]]:
"""Return the ProfileElement class that can contain a file with given path."""
naa = ProfileElementSequence.naa_for_path(path)
if naa:
@@ -1815,7 +1895,7 @@ class ProfileElementSequence:
return ProfileElementTelecom, ft
return ProfileElementGFM, None
def pe_for_path(self, path: Path) -> Optional[ProfileElement]:
def pe_for_path(self, path: Path) -> Tuple[Optional[ProfileElement], Optional[templates.FileTemplate]]:
"""Return the ProfileElement instance that can contain a file with matching path. This will
either be an existing PE within the sequence, or it will be a newly-allocated PE that is
inserted into the sequence."""
@@ -1881,7 +1961,10 @@ class ProfileElementSequence:
class FsNode:
"""A node in the filesystem hierarchy."""
"""A node in the filesystem hierarchy. Each node can have a parent node and any number of children.
Each node is identified uniquely within the parent by its numeric FID and its optional human-readable
name. Each node usually is associated with an instance of the File class for the actual content of
the file. FsNode is the base class used by more specific nodes, such as FsNode{EF,DF,ADF,MF}."""
def __init__(self, fid: int, parent: Optional['FsNode'], file: Optional[File] = None,
name: Optional[str] = None):
self.fid = fid
@@ -1936,7 +2019,7 @@ class FsNode:
return x
def walk(self, fn, **kwargs):
"""call 'fn(self, **kwargs) for the File."""
"""call 'fn(self, ``**kwargs``) for the File."""
return [fn(self, **kwargs)]
class FsNodeEF(FsNode):
@@ -2026,7 +2109,7 @@ class FsNodeDF(FsNode):
return cur
def walk(self, fn, **kwargs):
"""call 'fn(self, **kwargs) for the DF and recursively for all children."""
"""call 'fn(self, ``**kwargs``) for the DF and recursively for all children."""
ret = super().walk(fn, **kwargs)
for c in self.children.values():
ret += c.walk(fn, **kwargs)

366
pySim/esim/saip/batch.py Normal file
View File

@@ -0,0 +1,366 @@
"""Implementation of Personalization of eSIM profiles in SimAlliance/TCA Interoperable Profile:
Run a batch of N personalizations"""
# (C) 2025-2026 by sysmocom - s.f.m.c. GmbH <info@sysmocom.de>
#
# Author: nhofmeyr@sysmocom.de
#
# 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 copy
import pprint
import logging
import traceback
import inspect
from typing import List, Generator
from pySim.esim.saip.personalization import ConfigurableParameter
from pySim.esim.saip import param_source
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
class BatchPersonalization:
"""Produce a series of eSIM profiles from predefined parameters.
Personalization parameters are derived from pysim.esim.saip.param_source.ParamSource.
Usage example:
der_input = some_file.open('rb').read()
pes = ProfileElementSequence.from_der(der_input)
p = pers.BatchPersonalization(
n=10,
src_pes=pes,
csv_rows=get_csv_reader())
p.add_param_and_src(
personalization.Iccid(),
param_source.IncDigitSource(
num_digits=18,
first_value=123456789012340001,
last_value=123456789012340010))
# add more parameters here, using ConfigurableParameter and ParamSource subclass instances to define the profile
# ...
# generate all 10 profiles (from n=10 above)
for result_pes in p.generate_profiles():
upp = result_pes.to_der()
store_upp(upp)
"""
class ParamAndSrc:
'tie a ConfigurableParameter to a source of actual values'
def __init__(self, param: ConfigurableParameter, src: param_source.ParamSource):
self.param = param
self.src = src
def __init__(self,
n: int,
src_pes: ProfileElementSequence,
params: list[ParamAndSrc]=None,
csv_rows: Generator=None,
):
"""
n: number of eSIM profiles to generate.
src_pes: a decoded eSIM profile as ProfileElementSequence, to serve as template. This is not modified, only
copied.
params: list of ParamAndSrc instances, defining a ConfigurableParameter and corresponding ParamSource to fill in
profile values.
csv_rows: A list or generator producing all CSV rows one at a time, starting with a row containing the column
headers. This is compatible with the python csv.reader. Each row gets passed to
ParamSource.get_next(), such that ParamSource implementations can access the row items.
See param_source.CsvSource.
"""
self.n = n
self.params = params or []
self.src_pes = src_pes
self.csv_rows = csv_rows
def add_param_and_src(self, param:ConfigurableParameter, src:param_source.ParamSource):
self.params.append(BatchPersonalization.ParamAndSrc(param=param, src=src))
def generate_profiles(self):
# get first row of CSV: column names
csv_columns = None
if self.csv_rows:
try:
csv_columns = next(self.csv_rows)
except StopIteration as e:
raise ValueError('the input CSV file appears to be empty') from e
for i in range(self.n):
csv_row = None
if self.csv_rows and csv_columns:
try:
csv_row_list = next(self.csv_rows)
except StopIteration as e:
raise ValueError(f'not enough rows in the input CSV for eSIM nr {i+1} of {self.n}') from e
csv_row = dict(zip(csv_columns, csv_row_list))
pes = copy.deepcopy(self.src_pes)
for p in self.params:
try:
input_value = p.src.get_next(csv_row=csv_row)
assert input_value is not None
value = p.param.__class__.validate_val(input_value)
p.param.__class__.apply_val(pes, value)
except Exception as e:
print(traceback.format_exc())
logger.error('during %s: %r', _func_(), e)
raise ValueError(f'{p.param.name} fed by {p.src.name}: {e!r}') from e
yield pes
class UppAudit(dict):
"""
Key-value pairs collected from a single UPP DER or PES.
UppAudit itself is a dict, callers may use the standard python dict API to access key-value pairs read from the UPP.
"""
@classmethod
def from_der(cls, der: bytes, params: List, der_size=False, additional_sd_keys=False):
'''return a dict of parameter name and set of selected parameter values found in a DER encoded profile. Note:
some ConfigurableParameter implementations return more than one key-value pair, for example, Imsi returns
both 'IMSI' and 'IMSI-ACC' parameters.
e.g.
UppAudit.from_der(my_der, [Imsi, ])
--> {'IMSI': '001010000000023', 'IMSI-ACC': '5'}
(where 'IMSI' == Imsi.name)
Read all parameters listed in params. params is a list of either ConfigurableParameter classes or
ConfigurableParameter class instances. This calls only classmethods, so each entry in params can either be the
class itself, or a class-instance of, a (non-abstract) ConfigurableParameter subclass.
For example, params = [Imsi, ] is equivalent to params = [Imsi(), ].
For der_size=True, also include a {'der_size':12345} entry.
For additional_sd_keys=True, output also all Security Domain KVN that there are *no* ConfigurableParameter
subclasses for. For example, SCP80 has reserved kvn 0x01..0x0f, but we offer only Scp80Kvn01, Scp80Kvn02,
Scp80Kvn03. So we would not show kvn 0x04..0x0f in an audit. additional_sd_keys=True includes audits of all SD
key KVN there may be in the UPP. This helps to spot SD keys that may already be present in a UPP template, with
unexpected / unusual kvn.
'''
# make an instance of this class
upp_audit = cls()
if der_size:
upp_audit['der_size'] = set((len(der), ))
pes = ProfileElementSequence.from_der(der)
for param in params:
try:
for valdict in param.get_values_from_pes(pes):
upp_audit.add_values(valdict)
except Exception as e:
raise ValueError(f'Error during audit for parameter {param}: {e}') from e
if not additional_sd_keys:
return upp_audit
# additional_sd_keys
for pe in pes.pe_list:
if pe.type != 'securityDomain':
continue
assert isinstance(pe, ProfileElementSD)
for key in pe.keys:
audit_key = f'SdKey_KVN{key.key_version_number:02x}_ID{key.key_identifier:02x}'
kuq_bin = KeyUsageQualifier.build(key.key_usage_qualifier).hex()
audit_val = f'{key.key_components=!r} key_usage_qualifier=0x{kuq_bin}={key.key_usage_qualifier!r}'
upp_audit[audit_key] = set((audit_val, ))
return upp_audit
def get_single_val(self, key, validate=True, allow_absent=False, absent_val=None):
"""
Return the audit's value for the given audit key (like 'IMSI' or 'IMSI-ACC').
Any kind of value may occur multiple times in a profile. When all of these agree to the same unambiguous value,
return that value. When they do not agree, raise a ValueError.
"""
# key should be a string, but if someone passes a ConfigurableParameter, just use its default name
if ConfigurableParameter.is_super_of(key):
key = key.get_name()
assert isinstance(key, str)
v = self.get(key)
if v is None and allow_absent:
return absent_val
if not isinstance(v, set):
raise ValueError(f'audit value should be a set(), got {v!r}')
if len(v) != 1:
raise ValueError(f'expected a single value for {key}, got {v!r}')
v = tuple(v)[0]
return v
@staticmethod
def audit_val_to_str(v):
"""
Usually, we want to see a single value in an audit. Still, to be able to collect multiple ambiguous values,
audit values are always python sets. Turn it into a nice string representation: only the value when it is
unambiguous, otherwise a list of the ambiguous values.
A value may also be completely absent, then return 'not present'.
"""
def try_single_val(w):
'change single-entry sets to just the single value'
if isinstance(w, set):
if len(w) == 1:
return tuple(w)[0]
if len(w) == 0:
return None
return w
v = try_single_val(v)
if isinstance(v, bytes):
v = bytes_to_hexstr(v)
if v is None:
return 'not present'
return str(v)
def get_val_str(self, key):
"""Return a string of the value stored for the given key"""
return UppAudit.audit_val_to_str(self.get(key))
def add_values(self, src:dict):
"""self and src are both a dict of sets.
For example from
self == { 'a': set((123,)) }
and
src == { 'a': set((456,)), 'b': set((789,)) }
then after this function call:
self == { 'a': set((123, 456,)), 'b': set((789,)) }
"""
assert isinstance(src, dict)
for key, srcvalset in src.items():
dstvalset = self.get(key)
if dstvalset is None:
dstvalset = set()
self[key] = dstvalset
dstvalset.add(srcvalset)
def __str__(self):
return '\n'.join(f'{key}: {self.get_val_str(key)}' for key in sorted(self.keys()))
class BatchAudit(list):
"""
Collect UppAudit instances for a batch of UPP, for example from a personalization.BatchPersonalization.
Produce an output CSV.
Usage example:
ba = BatchAudit(params=(personalization.Iccid, ))
for upp_der in upps:
ba.add_audit(upp_der)
print(ba.summarize())
with open('output.csv', 'wb') as csv_data:
csv_str = io.TextIOWrapper(csv_data, 'utf-8', newline='')
csv.writer(csv_str).writerows( ba.to_csv_rows() )
csv_str.flush()
BatchAudit itself is a list, callers may use the standard python list API to access the UppAudit instances.
"""
def __init__(self, params:List):
assert params
self.params = params
def add_audit(self, upp_der:bytes):
audit = UppAudit.from_der(upp_der, self.params)
self.append(audit)
return audit
def summarize(self):
batch_audit = UppAudit()
audits = self
if len(audits) > 2:
val_sep = ', ..., '
else:
val_sep = ', '
first_audit = None
last_audit = None
if len(audits) >= 1:
first_audit = audits[0]
if len(audits) >= 2:
last_audit = audits[-1]
if first_audit:
if last_audit:
for key in first_audit.keys():
first_val = first_audit.get_val_str(key)
last_val = last_audit.get_val_str(key)
if first_val == last_val:
val = first_val
else:
val_sep_with_newline = f"{val_sep.rstrip()}\n{' ' * (len(key) + 2)}"
val = val_sep_with_newline.join((first_val, last_val))
batch_audit[key] = val
else:
batch_audit.update(first_audit)
return batch_audit
def to_csv_rows(self, headers=True, sort_key=None):
'''generator that yields all audits' values as rows, useful feed to a csv.writer.'''
columns = set()
for audit in self:
columns.update(audit.keys())
columns = tuple(sorted(columns, key=sort_key))
if headers:
yield columns
for audit in self:
yield (audit.get_single_val(col, allow_absent=True, absent_val="") for col in columns)
def bytes_to_hexstr(b:bytes, sep=''):
return sep.join(f'{x:02x}' for x in b)
def esim_profile_introspect(upp):
pes = ProfileElementSequence.from_der(upp.read())
d = {}
d['upp'] = repr(pes)
def show_bytes_as_hexdump(item):
if isinstance(item, bytes):
return bytes_to_hexstr(item)
if isinstance(item, list):
return list(show_bytes_as_hexdump(i) for i in item)
if isinstance(item, tuple):
return tuple(show_bytes_as_hexdump(i) for i in item)
if isinstance(item, dict):
d = {}
for k, v in item.items():
d[k] = show_bytes_as_hexdump(v)
return d
return item
l = list((pe.type, show_bytes_as_hexdump(pe.decoded)) for pe in pes)
d['pp'] = pprint.pformat(l, width=120)
return d

View File

@@ -0,0 +1,228 @@
# Implementation of SimAlliance/TCA Interoperable Profile handling: parameter sources for batch personalization.
#
# (C) 2025 by sysmocom - s.f.m.c. GmbH <info@sysmocom.de>
#
# Author: nhofmeyr@sysmocom.de
#
# 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 secrets
import re
from osmocom.utils import b2h
class ParamSourceExn(Exception):
pass
class ParamSourceExhaustedExn(ParamSourceExn):
pass
class ParamSourceUndefinedExn(ParamSourceExn):
pass
class ParamSource:
"""abstract parameter source. For usage, see personalization.BatchPersonalization."""
# This name should be short but descriptive, useful for a user interface, like 'random decimal digits'.
name = "none"
numeric_base = None # or 10 or 16
@classmethod
def from_str(cls, s:str):
"""Subclasses implement this:
if a parameter source defines some string input magic, override this function.
For example, a RandomDigitSource derives the number of digits from the string length,
so the user can enter '0000' to get a four digit random number."""
return cls(s)
def get_next(self, csv_row:dict=None):
"""Subclasses implement this: return the next value from the parameter source.
When there are no more values from the source, raise a ParamSourceExhaustedExn.
This default implementation is an empty source."""
raise ParamSourceExhaustedExn()
class ConstantSource(ParamSource):
"""one value for all"""
name = "constant"
def __init__(self, val:str):
self.val = val
def get_next(self, csv_row:dict=None):
return self.val
class InputExpandingParamSource(ParamSource):
@classmethod
def expand_str(cls, s:str):
# user convenience syntax '0*32' becomes '00000000000000000000000000000000'
if "*" not in s:
return s
tokens = re.split(r"([^ \t]+)[ \t]*\*[ \t]*([0-9]+)", s)
if len(tokens) < 3:
return s
parts = []
for unchanged, snippet, repeat_str in zip(tokens[0::3], tokens[1::3], tokens[2::3]):
parts.append(unchanged)
repeat = int(repeat_str)
parts.append(snippet * repeat)
return "".join(parts)
@classmethod
def from_str(cls, s:str):
return cls(cls.expand_str(s))
class DecimalRangeSource(InputExpandingParamSource):
"""abstract: decimal numbers with a value range"""
numeric_base = 10
def __init__(self, num_digits, first_value, last_value):
"""
See also from_str().
All arguments are integer values, and are converted to int if necessary, so a string of an integer is fine.
num_digits: fixed number of digits (possibly with leading zeros) to generate.
first_value, last_value: the decimal range in which to provide digits.
"""
num_digits = int(num_digits)
first_value = int(first_value)
last_value = int(last_value)
assert num_digits > 0
assert first_value <= last_value
self.num_digits = num_digits
self.val_first_last = (first_value, last_value)
def val_to_digit(self, val:int):
return "%0*d" % (self.num_digits, val) # pylint: disable=consider-using-f-string
@classmethod
def from_str(cls, s:str):
s = cls.expand_str(s)
if ".." in s:
first_str, last_str = s.split('..')
first_str = first_str.strip()
last_str = last_str.strip()
else:
first_str = s.strip()
last_str = None
first_value = int(first_str)
last_value = int(last_str) if last_str is not None else "9" * len(first_str)
return cls(num_digits=len(first_str), first_value=first_value, last_value=last_value)
class RandomSourceMixin:
random_impl = secrets.SystemRandom()
class RandomDigitSource(DecimalRangeSource, RandomSourceMixin):
"""return a different sequence of random decimal digits each"""
name = "random decimal digits"
used_keys = set()
def get_next(self, csv_row:dict=None):
# try to generate random digits that are always different from previously produced random bytes
attempts = 10
while True:
val = self.random_impl.randint(*self.val_first_last)
if val in RandomDigitSource.used_keys:
attempts -= 1
if attempts:
continue
RandomDigitSource.used_keys.add(val)
break
return self.val_to_digit(val)
class RandomHexDigitSource(InputExpandingParamSource, RandomSourceMixin):
"""return a different sequence of random hexadecimal digits each"""
name = "random hexadecimal digits"
numeric_base = 16
used_keys = set()
def __init__(self, num_digits):
"""see from_str()"""
num_digits = int(num_digits)
if num_digits < 1:
raise ValueError("zero number of digits")
# hex digits always come in two
if (num_digits & 1) != 0:
raise ValueError(f"hexadecimal value should have even number of digits, not {num_digits}")
self.num_digits = num_digits
def get_next(self, csv_row:dict=None):
# try to generate random bytes that are always different from previously produced random bytes
attempts = 10
while True:
val = self.random_impl.randbytes(self.num_digits // 2)
if val in RandomHexDigitSource.used_keys:
attempts -= 1
if attempts:
continue
RandomHexDigitSource.used_keys.add(val)
break
return b2h(val)
@classmethod
def from_str(cls, s:str):
s = cls.expand_str(s)
return cls(num_digits=len(s.strip()))
class IncDigitSource(DecimalRangeSource):
"""incrementing sequence of digits"""
name = "incrementing decimal digits"
def __init__(self, num_digits, first_value, last_value):
super().__init__(num_digits, first_value, last_value)
self.next_val = None
self.reset()
def reset(self):
"""Restart from the first value of the defined range passed to __init__()."""
self.next_val = self.val_first_last[0]
def get_next(self, csv_row:dict=None):
val = self.next_val
if val is None:
raise ParamSourceExhaustedExn()
returnval = self.val_to_digit(val)
val += 1
if val > self.val_first_last[1]:
self.next_val = None
else:
self.next_val = val
return returnval
class CsvSource(ParamSource):
"""apply a column from a CSV row, as passed in to ParamSource.get_next(csv_row)"""
name = "from CSV"
def __init__(self, csv_column):
"""
csv_column: column name indicating the column to use for this parameter.
This name is used in get_next(): the caller passes the current CSV row to get_next(), from which
CsvSource picks the column with the name matching csv_column.
"""
self.csv_column = csv_column
def get_next(self, csv_row:dict=None):
val = None
if csv_row:
val = csv_row.get(self.csv_column)
if not val:
raise ParamSourceUndefinedExn(f"no value for CSV column {self.csv_column!r}")
return val

File diff suppressed because it is too large Load Diff

View File

@@ -673,7 +673,7 @@ class FilesUsimDf5GS(ProfileTemplate):
FileTemplate(0x4f06, 'EF.UAC_AIC', 'TR', None, 4, 2, 0x06, None, True, ass_serv=[126]),
FileTemplate(0x4f07, 'EF.SUCI_Calc_Info', 'TR', None, None, 2, 0x07, 'FF...FF', False, ass_serv=[124]),
FileTemplate(0x4f08, 'EF.OPL5G', 'LF', None, 10, 10, 0x08, 'FF...FF', False, ['nb_rec'], ass_serv=[129]),
FileTemplate(0x4f09, 'EF.SUPI_NAI', 'TR', None, None, 2, 0x09, None, True, ['size'], ass_serv=[130]),
FileTemplate(0x4f09, 'EF.SUPI_NAI', 'TR', None, None, 2, 0x09, None, True, ['size'], ass_serv=[130], pe_name='ef-supinai'),
FileTemplate(0x4f0a, 'EF.Routing_Indicator', 'TR', None, 4, 2, 0x0a, 'F0FFFFFF', False, ass_serv=[124]),
]
@@ -818,7 +818,7 @@ class FilesIsimOptional(ProfileTemplate):
base_path = Path('ADF.ISIM')
extends = FilesIsimMandatory
files = [
FileTemplate(0x6f09, 'EF.P-CSCF', 'LF', 1, None, 2, None, None, True, ['size'], ass_serv=[1,5]),
FileTemplate(0x6f09, 'EF.P-CSCF', 'LF', 1, None, 2, None, None, True, ['size'], ass_serv=[1,5], pe_name='ef-pcscf'),
FileTemplate(0x6f3c, 'EF.SMS', 'LF', 10, 176, 5, None, '00FF...FF', False, ass_serv=[6,8]),
FileTemplate(0x6f42, 'EF.SMSP', 'LF', 1, 38, 5, None, 'FF...FF', False, ass_serv=[8]),
FileTemplate(0x6f43, 'EF.SMSS', 'TR', None, 2, 5, None, 'FFFF', False, ass_serv=[6,8]),

View File

@@ -103,6 +103,26 @@ class CheckBasicStructure(ProfileConstraintChecker):
if 'profile-a-p256' in m_svcs and not ('usim' in m_svcs or 'isim' in m_svcs):
raise ProfileError('profile-a-p256 mandatory, but no usim or isim')
def check_mandatory_services_aka(self, pes: ProfileElementSequence):
"""Ensure that no unnecessary authentication related services are marked as mandatory but not
actually used within the profile"""
m_svcs = pes.get_pe_for_type('header').decoded['eUICC-Mandatory-services']
# list of tuples (algo_id, key_len_in_octets) for all the akaParameters in the PE Sequence
algo_id_klen = [(x.decoded['algoConfiguration'][1]['algorithmID'],
len(x.decoded['algoConfiguration'][1]['key'])) for x in pes.get_pes_for_type('akaParameter')]
# just a plain list of algorithm IDs in akaParameters
algorithm_ids = [x[0] for x in algo_id_klen]
if 'milenage' in m_svcs and not 1 in algorithm_ids:
raise ProfileError('milenage mandatory, but no related algorithm_id in akaParameter')
if 'tuak128' in m_svcs and not (2, 128/8) in algo_id_klen:
raise ProfileError('tuak128 mandatory, but no related algorithm_id in akaParameter')
if 'cave' in m_svcs and not pes.get_pe_for_type('cdmaParameter'):
raise ProfileError('cave mandatory, but no related cdmaParameter')
if 'tuak256' in m_svcs and (2, 256/8) in algo_id_klen:
raise ProfileError('tuak256 mandatory, but no related algorithm_id in akaParameter')
if 'usim-test-algorithm' in m_svcs and not 3 in algorithm_ids:
raise ProfileError('usim-test-algorithm mandatory, but no related algorithm_id in akaParameter')
def check_identification_unique(self, pes: ProfileElementSequence):
"""Ensure that each PE has a unique identification value."""
id_list = [pe.header['identification'] for pe in pes.pe_list if pe.header]

View File

@@ -181,7 +181,7 @@ class SeqNumber(BER_TLV_IE, tag=0x80):
class NotificationAddress(BER_TLV_IE, tag=0x0c):
_construct = Utf8Adapter(GreedyBytes)
class Iccid(BER_TLV_IE, tag=0x5a):
_construct = BcdAdapter(GreedyBytes)
_construct = PaddedBcdAdapter(GreedyBytes)
class NotificationMetadata(BER_TLV_IE, tag=0xbf2f, nested=[SeqNumber, ProfileMgmtOperation,
NotificationAddress, Iccid]):
pass

View File

@@ -30,6 +30,7 @@ import tempfile
import json
import abc
import inspect
import os
import cmd2
from cmd2 import CommandSet, with_default_category
@@ -552,6 +553,85 @@ class CardADF(CardDF):
return lchan.selected_file.application.export(as_json, lchan)
class JsonEditor:
"""Context manager for editing a JSON-encoded EF value in an external editor.
Writes the current JSON value (plus encode/decode examples as //-comments)
to a temporary file, opens the user's editor, then reads the result back
(stripping comment lines) and returns it as the context variable::
with JsonEditor(self._cmd, orig_json, ef) as edited_json:
if edited_json != orig_json:
...write back...
"""
def __init__(self, cmd, orig_json, ef):
self._cmd = cmd
self._orig_json = orig_json
self._ef = ef
self._file = None
@staticmethod
def _strip_comments(text: str) -> str:
"""Strip //-comment lines from text before JSON parsing."""
# TODO: also strip inline comments?
return '\n'.join(line for line in text.splitlines() if not line.lstrip().startswith('//'))
def _append_examples_as_comments(self, text_file) -> None:
"""Append encode/decode test vectors as //-comment lines to an open file.
The examples are taken from _test_de_encode and _test_decode class
attributes (same source as the auto-generated filesystem documentation).
The comment block is intentionally ignored on read-back by _strip_comments."""
vectors = []
for attr in ('_test_de_encode', '_test_decode'):
v = getattr(type(self._ef), attr, None)
if v:
vectors.extend(v)
if not vectors:
return
ef = self._ef
parts = [ef.fully_qualified_path_str()]
if ef.fid:
parts.append(f'({ef.fid.upper()})')
if ef.desc:
parts.append(f'- {ef.desc}')
text_file.write(f'\n\n// {" ".join(parts)}\n')
text_file.write('// Examples (ignored on save):\n')
for t in vectors:
if len(t) >= 3:
encoded, record_nr, decoded = t[0], t[1], t[2]
text_file.write(f'// record {record_nr}: {encoded}\n')
else:
encoded, decoded = t[0], t[1]
text_file.write(f'// file: {encoded}\n')
for line in json.dumps(decoded, indent=4, cls=JsonEncoder).splitlines():
text_file.write(f'// {line}\n')
def __enter__(self) -> object:
"""Write JSON + examples to a temp file, run the editor, return parsed result.
On JSONDecodeError the user is offered the option to re-open the file
and fix the mistake interactively. The temp file is removed by __exit__()
on success, or when the user declines to retry."""
self._file = tempfile.NamedTemporaryFile(prefix='pysim_', suffix='.json',
mode='w', delete=False)
json.dump(self._orig_json, self._file, indent=4, cls=JsonEncoder)
self._append_examples_as_comments(self._file)
self._file.close()
while True:
self._cmd.run_editor(self._file.name)
try:
with open(self._file.name, 'r') as f:
return json.loads(self._strip_comments(f.read()))
except json.JSONDecodeError as e:
self._cmd.perror(f'Invalid JSON: {e}')
answer = self._cmd.read_input('Re-open file for editing? [y]es/[n]o: ')
if answer not in ('y', 'yes'):
return self._orig_json
def __exit__(self, *args):
os.unlink(self._file.name)
class CardEF(CardFile):
"""EF (Entry File) in the smart card filesystem"""
@@ -657,15 +737,8 @@ class TransparentEF(CardEF):
def do_edit_binary_decoded(self, _opts):
"""Edit the JSON representation of the EF contents in an editor."""
(orig_json, _sw) = self._cmd.lchan.read_binary_dec()
with tempfile.TemporaryDirectory(prefix='pysim_') as dirname:
filename = '%s/file' % dirname
# write existing data as JSON to file
with open(filename, 'w') as text_file:
json.dump(orig_json, text_file, indent=4, cls=JsonEncoder)
# run a text editor
self._cmd.run_editor(filename)
with open(filename, 'r') as text_file:
edited_json = json.load(text_file)
ef = self._cmd.lchan.selected_file
with JsonEditor(self._cmd, orig_json, ef) as edited_json:
if edited_json == orig_json:
self._cmd.poutput("Data not modified, skipping write")
else:
@@ -959,15 +1032,8 @@ class LinFixedEF(CardEF):
def do_edit_record_decoded(self, opts):
"""Edit the JSON representation of one record in an editor."""
(orig_json, _sw) = self._cmd.lchan.read_record_dec(opts.RECORD_NR)
with tempfile.TemporaryDirectory(prefix='pysim_') as dirname:
filename = '%s/file' % dirname
# write existing data as JSON to file
with open(filename, 'w') as text_file:
json.dump(orig_json, text_file, indent=4, cls=JsonEncoder)
# run a text editor
self._cmd.run_editor(filename)
with open(filename, 'r') as text_file:
edited_json = json.load(text_file)
ef = self._cmd.lchan.selected_file
with JsonEditor(self._cmd, orig_json, ef) as edited_json:
if edited_json == orig_json:
self._cmd.poutput("Data not modified, skipping write")
else:

View File

@@ -276,7 +276,7 @@ class ListOfSupportedOptions(BER_TLV_IE, tag=0x81):
class SupportedKeysForScp03(BER_TLV_IE, tag=0x82):
_construct = FlagsEnum(Byte, aes128=0x01, aes192=0x02, aes256=0x04)
class SupportedTlsCipherSuitesForScp81(BER_TLV_IE, tag=0x83):
_consuruct = GreedyRange(Int16ub)
_construct = GreedyRange(Int16ub)
class ScpInformation(BER_TLV_IE, tag=0xa0, nested=[ScpType, ListOfSupportedOptions, SupportedKeysForScp03,
SupportedTlsCipherSuitesForScp81]):
pass
@@ -319,7 +319,7 @@ class CurrentSecurityLevel(BER_TLV_IE, tag=0xd3):
# GlobalPlatform v2.3.1 Section 11.3.3.1.3
class ApplicationAID(BER_TLV_IE, tag=0x4f):
_construct = GreedyBytes
class ApplicationTemplate(BER_TLV_IE, tag=0x61, ntested=[ApplicationAID]):
class ApplicationTemplate(BER_TLV_IE, tag=0x61, nested=[ApplicationAID]):
pass
class ListOfApplications(BER_TLV_IE, tag=0x2f00, nested=[ApplicationTemplate]):
pass
@@ -562,14 +562,14 @@ class ADF_SD(CardADF):
@cmd2.with_argparser(store_data_parser)
def do_store_data(self, opts):
"""Perform the GlobalPlatform GET DATA command in order to store some card-specific data.
See GlobalPlatform CardSpecification v2.3Section 11.11 for details."""
"""Perform the GlobalPlatform STORE DATA command in order to store some card-specific data.
See GlobalPlatform CardSpecification v2.3 Section 11.11 for details."""
response_permitted = opts.response == 'may_be_returned'
self.store_data(h2b(opts.DATA), opts.data_structure, opts.encryption, response_permitted)
def store_data(self, data: bytes, structure:str = 'none', encryption:str = 'none', response_permitted: bool = False) -> bytes:
"""Perform the GlobalPlatform GET DATA command in order to store some card-specific data.
See GlobalPlatform CardSpecification v2.3Section 11.11 for details."""
"""Perform the GlobalPlatform STORE DATA command in order to store some card-specific data.
See GlobalPlatform CardSpecification v2.3 Section 11.11 for details."""
max_cmd_len = self._cmd.lchan.scc.max_cmd_len
# Table 11-89 of GP Card Specification v2.3
remainder = data
@@ -585,7 +585,7 @@ class ADF_SD(CardADF):
data, _sw = self._cmd.lchan.scc.send_apdu_checksw(hdr + b2h(chunk) + "00")
block_nr += 1
response += data
return data
return h2b(response)
put_key_parser = argparse.ArgumentParser()
put_key_parser.add_argument('--old-key-version-nr', type=auto_uint8, default=0, help='Old Key Version Number')
@@ -859,22 +859,28 @@ class ADF_SD(CardADF):
_rsp_hex, _sw = self._cmd.lchan.scc.send_apdu_checksw(cmd_hex)
self._cmd.poutput("Loaded a total of %u bytes in %u blocks. Don't forget install_for_install (and make selectable) now!" % (total_size, block_nr))
install_cap_parser = argparse.ArgumentParser()
install_cap_parser = argparse.ArgumentParser(usage='%(prog)s FILE [--install-parameters | --install-parameters-*]')
install_cap_parser.add_argument('cap_file', type=str, metavar='FILE',
help='JAVA-CARD CAP file to install')
install_cap_parser_inst_prm_g = install_cap_parser.add_mutually_exclusive_group()
install_cap_parser_inst_prm_g.add_argument('--install-parameters', type=is_hexstr, default=None,
help='install Parameters (GPC_SPE_034, section 11.5.2.3.7, table 11-49)')
install_cap_parser_inst_prm_g_grp = install_cap_parser_inst_prm_g.add_argument_group()
install_cap_parser_inst_prm_g_grp.add_argument('--install-parameters-volatile-memory-quota',
type=int, default=None,
help='volatile memory quota (GPC_SPE_034, section 11.5.2.3.7, table 11-49)')
install_cap_parser_inst_prm_g_grp.add_argument('--install-parameters-non-volatile-memory-quota',
type=int, default=None,
help='non volatile memory quota (GPC_SPE_034, section 11.5.2.3.7, table 11-49)')
install_cap_parser_inst_prm_g_grp.add_argument('--install-parameters-stk',
type=is_hexstr, default=None,
help='Load Parameters (ETSI TS 102 226, section 8.2.1.3.2.1)')
# Ideally, the parser should enforce that:
# * either the `--install-parameters` is given alone,
# * or distinct `--install-parameters-*` are optionally given instead.
# We tried to achieve this using mutually exclusive groups (add_mutually_exclusive_group).
# However, group nesting was never supported, often failed to work correctly, and was unintentionally
# exposed through inheritance. It has been deprecated since version 3.11, removed in version 3.14.
# Hence, we have to implement the enforcement manually.
install_cap_parser_inst_prm_grp = install_cap_parser.add_argument_group('Install Parameters')
install_cap_parser_inst_prm_grp.add_argument('--install-parameters', type=is_hexstr, default=None,
help='install Parameters (GPC_SPE_034, section 11.5.2.3.7, table 11-49)')
install_cap_parser_inst_prm_grp.add_argument('--install-parameters-volatile-memory-quota',
type=int, default=None,
help='volatile memory quota (GPC_SPE_034, section 11.5.2.3.7, table 11-49)')
install_cap_parser_inst_prm_grp.add_argument('--install-parameters-non-volatile-memory-quota',
type=int, default=None,
help='non volatile memory quota (GPC_SPE_034, section 11.5.2.3.7, table 11-49)')
install_cap_parser_inst_prm_grp.add_argument('--install-parameters-stk',
type=is_hexstr, default=None,
help='Load Parameters (ETSI TS 102 226, section 8.2.1.3.2.1)')
@cmd2.with_argparser(install_cap_parser)
def do_install_cap(self, opts):
@@ -888,9 +894,17 @@ class ADF_SD(CardADF):
load_file_aid = cap.get_loadfile_aid()
module_aid = cap.get_applet_aid()
application_aid = module_aid
if opts.install_parameters:
if opts.install_parameters is not None:
# `--install-parameters` and `--install-parameters-*` are mutually exclusive
# make sure that none of `--install-parameters-*` is given; abort otherwise
if any(p is not None for p in [opts.install_parameters_non_volatile_memory_quota,
opts.install_parameters_volatile_memory_quota,
opts.install_parameters_stk]):
self.install_cap_parser.error('arguments --install-parameters-* are '
'not allowed with --install-parameters')
install_parameters = opts.install_parameters;
else:
# `--install-parameters-*` are all optional
install_parameters = gen_install_parameters(opts.install_parameters_non_volatile_memory_quota,
opts.install_parameters_volatile_memory_quota,
opts.install_parameters_stk)

View File

@@ -1,6 +1,6 @@
# GlobalPlatform install parameter generator
#
# (C) 2024 by Sysmocom s.f.m.c. GmbH
# (C) 2024 by sysmocom - s.f.m.c. GmbH
# All Rights Reserved
#
# This program is free software: you can redistribute it and/or modify
@@ -17,6 +17,8 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from typing import Optional
from osmocom.construct import *
from osmocom.utils import *
from osmocom.tlv import *
@@ -46,7 +48,9 @@ class InstallParams(TLV_IE_Collection, nested=[AppSpecificParams, SystemSpecific
# GPD_SPE_013, table 11-49
pass
def gen_install_parameters(non_volatile_memory_quota:int, volatile_memory_quota:int, stk_parameter:str):
def gen_install_parameters(non_volatile_memory_quota: Optional[int] = None,
volatile_memory_quota: Optional[int] = None,
stk_parameter: Optional[str] = None):
# GPD_SPE_013, table 11-49
@@ -54,19 +58,17 @@ def gen_install_parameters(non_volatile_memory_quota:int, volatile_memory_quota:
install_params = InstallParams()
install_params_dict = [{'app_specific_params': None}]
#Conditional
if non_volatile_memory_quota and volatile_memory_quota and stk_parameter:
system_specific_params = []
#Optional
if non_volatile_memory_quota:
system_specific_params += [{'non_volatile_memory_quota': non_volatile_memory_quota}]
#Optional
if volatile_memory_quota:
system_specific_params += [{'volatile_memory_quota': volatile_memory_quota}]
#Optional
if stk_parameter:
system_specific_params += [{'stk_parameter': stk_parameter}]
install_params_dict += [{'system_specific_params': system_specific_params}]
# Collect system specific parameters (optional)
system_specific_params = []
if non_volatile_memory_quota is not None:
system_specific_params.append({'non_volatile_memory_quota': non_volatile_memory_quota})
if volatile_memory_quota is not None:
system_specific_params.append({'volatile_memory_quota': volatile_memory_quota})
if stk_parameter is not None:
system_specific_params.append({'stk_parameter': stk_parameter})
# Add system specific parameters to the install parameters, if any
if system_specific_params:
install_params_dict.append({'system_specific_params': system_specific_params})
install_params.from_dict(install_params_dict)
return b2h(install_params.to_bytes())

View File

@@ -266,11 +266,13 @@ class SCP02(SCP):
super().__init__(*args, **kwargs)
def dek_encrypt(self, plaintext:bytes) -> bytes:
cipher = DES.new(self.card_keys.dek[:8], DES.MODE_ECB)
# See also GPC section B.1.1.2, E.4.7, and E.4.1
cipher = DES3.new(self.sk.data_enc, DES.MODE_ECB)
return cipher.encrypt(plaintext)
def dek_decrypt(self, ciphertext:bytes) -> bytes:
cipher = DES.new(self.card_keys.dek[:8], DES.MODE_ECB)
# See also GPC section B.1.1.2, E.4.7, and E.4.1
cipher = DES3.new(self.sk.data_enc, DES.MODE_ECB)
return cipher.decrypt(ciphertext)
def _compute_cryptograms(self, card_challenge: bytes, host_challenge: bytes):
@@ -436,7 +438,7 @@ class Scp03SessionKeys:
"""Obtain the ICV value computed as described in 6.2.6.
This method has two modes:
* is_response=False for computing the ICV for C-ENC. Will pre-increment the counter.
* is_response=False for computing the ICV for R-DEC."""
* is_response=True for computing the ICV for R-DEC."""
if not is_response:
self.block_nr += 1
# The binary value of this number SHALL be left padded with zeroes to form a full block.

View File

@@ -91,6 +91,7 @@ class UiccSdInstallParams(TLV_IE_Collection, nested=[UiccScp, AcceptExtradAppsAn
# Key Usage:
# KVN 0x01 .. 0x0F reserved for SCP80
# KVN 0x81 .. 0x8f reserved for SCP81
# KVN 0x11 reserved for DAP specified in ETSI TS 102 226
# KVN 0x20 .. 0x2F reserved for SCP02
# KID 0x01 = ENC; 0x02 = MAC; 0x03 = DEK

View File

@@ -1,6 +1,6 @@
# JavaCard related utilities
#
# (C) 2024 by Sysmocom s.f.m.c. GmbH
# (C) 2024 by sysmocom - s.f.m.c. GmbH
# All Rights Reserved
#
# This program is free software: you can redistribute it and/or modify

View File

@@ -4,7 +4,7 @@
"""
#
# (C) 2025 by Sysmocom s.f.m.c. GmbH
# (C) 2025 by sysmocom - s.f.m.c. GmbH
# All Rights Reserved
#
# Author: Philipp Maier <pmaier@sysmocom.de>
@@ -44,7 +44,7 @@ class PySimLogger:
"""
LOG_FMTSTR = "%(levelname)s: %(message)s"
LOG_FMTSTR_VERBOSE = "%(module)s.%(lineno)d -- %(name)s - " + LOG_FMTSTR
LOG_FMTSTR_VERBOSE = "%(module)s.%(lineno)d -- " + LOG_FMTSTR
__formatter = logging.Formatter(LOG_FMTSTR)
__formatter_verbose = logging.Formatter(LOG_FMTSTR_VERBOSE)
@@ -63,7 +63,7 @@ class PySimLogger:
raise RuntimeError('static class, do not instantiate')
@staticmethod
def setup(print_callback = None, colors:dict = {}):
def setup(print_callback = None, colors:dict = {}, verbose_debug:bool = False):
"""
Set a print callback function and color scheme. This function call is optional. In case this method is not
called, default settings apply.
@@ -72,10 +72,20 @@ class PySimLogger:
have the following format: print_callback(message:str)
colors : An optional dict through which certain log levels can be assigned a color.
(e.g. {logging.WARN: YELLOW})
verbose_debug: Enable verbose logging and set the loglevel DEBUG when set to true. Otherwise the
non-verbose logging is used and the loglevel is set to INFO. This setting can be changed
using the set_verbose and set_level methods at any time.
"""
PySimLogger.print_callback = print_callback
PySimLogger.colors = colors
if (verbose_debug):
PySimLogger.set_verbose(True)
PySimLogger.set_level(logging.DEBUG)
else:
PySimLogger.set_verbose(False)
PySimLogger.set_level(logging.INFO)
@staticmethod
def set_verbose(verbose:bool = False):
"""
@@ -108,7 +118,10 @@ class PySimLogger:
formatted_message = logging.Formatter.format(PySimLogger.__formatter, record)
color = PySimLogger.colors.get(record.levelno)
if color:
PySimLogger.print_callback(style(formatted_message, fg = color))
if isinstance(color, str):
PySimLogger.print_callback(color + formatted_message + "\033[0m")
else:
PySimLogger.print_callback(style(formatted_message, fg = color))
else:
PySimLogger.print_callback(formatted_message)

View File

@@ -57,12 +57,13 @@ CompactRemoteResp = Struct('number_of_commands'/Int8ub,
'last_response_data'/HexAdapter(GreedyBytes))
RC_CC_DS = Enum(BitsInteger(2), no_rc_cc_ds=0, rc=1, cc=2, ds=3)
CNTR_REQ = Enum(BitsInteger(2), no_counter=0, counter_no_replay_or_seq=1, counter_must_be_higher=2, counter_must_be_lower=3)
POR_REQ = Enum(BitsInteger(2), no_por=0, por_required=1, por_only_when_error=2)
# TS 102 225 Section 5.1.1 + TS 31.115 Section 4.2
SPI = BitStruct( # first octet
Padding(3),
'counter'/Enum(BitsInteger(2), no_counter=0, counter_no_replay_or_seq=1,
counter_must_be_higher=2, counter_must_be_lower=3),
'counter'/CNTR_REQ,
'ciphering'/Flag,
'rc_cc_ds'/RC_CC_DS,
# second octet
@@ -70,8 +71,7 @@ SPI = BitStruct( # first octet
'por_in_submit'/Flag,
'por_shall_be_ciphered'/Flag,
'por_rc_cc_ds'/RC_CC_DS,
'por'/Enum(BitsInteger(2), no_por=0,
por_required=1, por_only_when_error=2)
'por'/POR_REQ
)
# TS 102 225 Section 5.1.2
@@ -221,12 +221,12 @@ class OtaAlgoCrypt(OtaAlgo, abc.ABC):
for subc in cls.__subclasses__():
if subc.enum_name == otak.algo_crypt:
return subc(otak)
raise ValueError('No implementation for crypt algorithm %s' % otak.algo_auth)
raise ValueError('No implementation for crypt algorithm %s' % otak.algo_crypt)
class OtaAlgoAuth(OtaAlgo, abc.ABC):
def __init__(self, otak: OtaKeyset):
if self.enum_name != otak.algo_auth:
raise ValueError('Cannot use algorithm %s with key for %s' % (self.enum_name, otak.algo_crypt))
raise ValueError('Cannot use algorithm %s with key for %s' % (self.enum_name, otak.algo_auth))
super().__init__(otak)
def sign(self, data:bytes) -> bytes:

View File

@@ -4,7 +4,7 @@
"""
#
# (C) 2021 by Sysmocom s.f.m.c. GmbH
# (C) 2021 by sysmocom - s.f.m.c. GmbH
# All Rights Reserved
#
# This program is free software: you can redistribute it and/or modify

View File

@@ -1,4 +1,5 @@
# coding=utf-8
"""Representation of the runtime state of an application like pySim-shell.
"""
@@ -25,7 +26,7 @@ from pySim.exceptions import *
from pySim.filesystem import *
from pySim.log import PySimLogger
log = PySimLogger.get("RUNTIME")
log = PySimLogger.get(__name__)
def lchan_nr_from_cla(cla: int) -> int:
"""Resolve the logical channel number from the CLA byte."""
@@ -115,7 +116,7 @@ class RuntimeState:
for a in aids_unknown:
log.info(" unknown: %s (EF.DIR)" % a)
else:
log.warn("EF.DIR seems to be empty!")
log.warning("EF.DIR seems to be empty!")
# Some card applications may not be registered in EF.DIR, we will actively
# probe for those applications
@@ -477,11 +478,15 @@ class RuntimeLchan:
def get_file_for_filename(self, name: str):
"""Get the related CardFile object for a specified filename."""
if is_hex(name):
name = name.lower()
sels = self.selected_file.get_selectables()
return sels[name]
def activate_file(self, name: str):
"""Request ACTIVATE FILE of specified file."""
if is_hex(name):
name = name.lower()
sels = self.selected_file.get_selectables()
f = sels[name]
data, sw = self.scc.activate_file(f.fid)
@@ -552,8 +557,8 @@ class RuntimeLchan:
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))
log.warning("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.

View File

@@ -169,8 +169,14 @@ class SMS_TPDU(abc.ABC):
class SMS_DELIVER(SMS_TPDU):
"""Representation of a SMS-DELIVER T-PDU. This is the Network to MS/UE (downlink) direction."""
flags_construct = BitStruct('tp_rp'/Flag, 'tp_udhi'/Flag, 'tp_rp'/Flag, 'tp_sri'/Flag,
Padding(1), 'tp_mms'/Flag, 'tp_mti'/BitsInteger(2))
flags_construct = BitStruct('tp_rp'/Flag,
'tp_udhi'/Flag,
'tp_sri'/Flag,
Padding(1),
'tp_lp'/Flag,
'tp_mms'/Flag,
'tp_mti'/BitsInteger(2))
def __init__(self, **kwargs):
kwargs['tp_mti'] = 0
super().__init__(**kwargs)

View File

@@ -3,18 +3,6 @@
""" pySim: PCSC reader transport link base
"""
import os
import abc
import argparse
from typing import Optional, Tuple
from construct import Construct
from osmocom.utils import b2h, h2b, i2h, Hexstr
from pySim.exceptions import *
from pySim.utils import SwHexstr, SwMatchstr, ResTuple, sw_match, parse_command_apdu
from pySim.cat import ProactiveCommand, CommandDetails, DeviceIdentities, Result
#
# Copyright (C) 2009-2010 Sylvain Munaut <tnt@246tNt.com>
# Copyright (C) 2021-2023 Harald Welte <laforge@osmocom.org>
#
@@ -30,8 +18,20 @@ from pySim.cat import ProactiveCommand, CommandDetails, DeviceIdentities, Result
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import os
import abc
import argparse
from typing import Optional, Tuple
from construct import Construct
from osmocom.utils import b2h, h2b, i2h, Hexstr
from pySim.exceptions import *
from pySim.utils import SwHexstr, SwMatchstr, ResTuple, sw_match, parse_command_apdu
from pySim.cat import ProactiveCommand, CommandDetails, DeviceIdentities, Result
from pySim.log import PySimLogger
log = PySimLogger.get(__name__)
class ApduTracer:
def trace_command(self, cmd):
@@ -46,11 +46,11 @@ class ApduTracer:
class StdoutApduTracer(ApduTracer):
"""Minimalistic APDU tracer, printing commands to stdout."""
def trace_response(self, cmd, sw, resp):
print("-> %s %s" % (cmd[:10], cmd[10:]))
print("<- %s: %s" % (sw, resp))
log.info("-> %s %s", cmd[:10], cmd[10:])
log.info("<- %s: %s", sw, resp)
def trace_reset(self):
print("-- RESET")
log.info("-- RESET")
class ProactiveHandler(abc.ABC):
"""Abstract base class representing the interface of some code that handles
@@ -90,7 +90,7 @@ class LinkBase(abc.ABC):
self.sw_interpreter = sw_interpreter
self.apdu_tracer = apdu_tracer
self.proactive_handler = proactive_handler
self.apdu_strict = False
self.apdu_strict = True
@abc.abstractmethod
def __str__(self) -> str:
@@ -177,7 +177,7 @@ class LinkBase(abc.ABC):
if self.apdu_strict:
raise ValueError(exeption_str)
else:
print('Warning: %s' % exeption_str)
log.warning(exeption_str)
return (data, sw)
@@ -211,7 +211,7 @@ class LinkBase(abc.ABC):
# parse the proactive command
pcmd = ProactiveCommand()
parsed = pcmd.from_tlv(h2b(fetch_rv[0]))
print("FETCH: %s (%s)" % (fetch_rv[0], type(parsed).__name__))
log.info("FETCH: %s (%s)", fetch_rv[0], type(parsed).__name__)
if self.proactive_handler:
# Extension point: If this does return a list of TLV objects,
# they could be appended after the Result; if the first is a
@@ -361,13 +361,13 @@ def init_reader(opts, **kwargs) -> LinkBase:
from pySim.transport.modem_atcmd import ModemATCommandLink
sl = ModemATCommandLink(opts, **kwargs)
else: # Serial reader is default
print("No reader/driver specified; falling back to default (Serial reader)")
log.warning("No reader/driver specified; falling back to default (Serial reader)")
from pySim.transport.serial import SerialSimLink
sl = SerialSimLink(opts, **kwargs)
if os.environ.get('PYSIM_INTEGRATION_TEST') == "1":
print("Using %s reader interface" % (sl.name))
log.info("Using %s reader interface" % (sl.name))
else:
print("Using reader %s" % sl)
log.info("Using reader %s" % sl)
return sl

View File

@@ -166,7 +166,7 @@ class ModemATCommandLink(LinkBaseTpdu):
# Make sure that the response has format: b'+CSIM: %d,\"%s\"'
try:
result = re.match(b'\+CSIM: (\d+),\"([0-9A-F]+)\"', rsp)
result = re.match(rb'\+CSIM: (\d+),\"([0-9A-F]+)\"', rsp)
(_rsp_tpdu_len, rsp_tpdu) = result.groups()
except Exception as exc:
raise ReaderError('Failed to parse response from modem: %s' % rsp) from exc

View File

@@ -26,6 +26,7 @@ from smartcard.CardRequest import CardRequest
from smartcard.Exceptions import NoCardException, CardRequestTimeoutException, CardConnectionException
from smartcard.System import readers
from smartcard.ExclusiveConnectCardConnection import ExclusiveConnectCardConnection
from smartcard.ATR import ATR
from osmocom.utils import h2i, i2h, Hexstr
@@ -80,23 +81,25 @@ class PcscSimLink(LinkBaseTpdu):
def connect(self):
try:
# To avoid leakage of resources, make sure the reader
# is disconnected
# To avoid leakage of resources, make sure the reader is disconnected
self.disconnect()
# Make card connection and select a suitable communication protocol
# (Even though pyscard provides an automatic protocol selection, we will make an independent decision
# based on the ATR. There are two reasons for that:
# 1) In case a card supports T=0 and T=1, we perfer to use T=0.
# 2) The automatic protocol selection may be unreliabe on some platforms
# see also: https://osmocom.org/issues/6952)
self._con.connect()
supported_protocols = self._con.getProtocol();
self.disconnect()
if (supported_protocols & CardConnection.T0_protocol):
protocol = CardConnection.T0_protocol
atr = ATR(self._con.getATR())
if atr.isT0Supported():
self._con.setProtocol(CardConnection.T0_protocol)
self.set_tpdu_format(0)
elif (supported_protocols & CardConnection.T1_protocol):
protocol = CardConnection.T1_protocol
elif atr.isT1Supported():
self._con.setProtocol(CardConnection.T1_protocol)
self.set_tpdu_format(1)
else:
raise ReaderError('Unsupported card protocol')
self._con.connect(protocol)
except CardConnectionException as exc:
raise ProtocolError() from exc
except NoCardException as exc:

View File

@@ -750,7 +750,7 @@ class EF_ARR(LinFixedEF):
@cmd2.with_argparser(LinFixedEF.ShellCommands.read_rec_dec_parser)
def do_read_arr_record(self, opts):
"""Read one EF.ARR record in flattened, human-friendly form."""
(data, _sw) = self._cmd.lchan.read_record_dec(opts.record_nr)
(data, _sw) = self._cmd.lchan.read_record_dec(opts.RECORD_NR)
data = self._cmd.lchan.selected_file.flatten(data)
self._cmd.poutput_json(data, opts.oneline)

View File

@@ -486,17 +486,17 @@ class EF_UST(EF_UServiceTable):
# TS 31.103 Section 4.2.7 - *not* the same as DF.GSM/EF.ECC!
class EF_ECC(LinFixedEF):
_test_de_encode = [
( '19f1ff01', { "call_code": "911f",
( '19f1ff01', { "call_code": "911",
"service_category": { "police": True, "ambulance": False, "fire_brigade": False,
"marine_guard": False, "mountain_rescue": False,
"manual_ecall": False, "automatic_ecall": False } } ),
( '19f3ff02', { "call_code": "913f",
( '19f3ff02', { "call_code": "913",
"service_category": { "police": False, "ambulance": True, "fire_brigade": False,
"marine_guard": False, "mountain_rescue": False,
"manual_ecall": False, "automatic_ecall": False } } ),
]
_test_no_pad = True
cc_construct = BcdAdapter(Rpad(Bytes(3)))
cc_construct = PaddedBcdAdapter(Rpad(Bytes(3)))
category_construct = FlagsEnum(Byte, police=1, ambulance=2, fire_brigade=3, marine_guard=4,
mountain_rescue=5, manual_ecall=6, automatic_ecall=7)
alpha_construct = GsmOrUcs2Adapter(Rpad(GreedyBytes))
@@ -596,7 +596,7 @@ class EF_ICI(CyclicEF):
self._construct = Struct('alpha_id'/Bytes(this._.total_len-28),
'len_of_bcd_contents'/Int8ub,
'ton_npi'/Int8ub,
'call_number'/BcdAdapter(Bytes(10)),
'call_number'/PaddedBcdAdapter(Rpad(Bytes(10))),
'cap_cfg2_record_id'/Int8ub,
'ext5_record_id'/Int8ub,
'date_and_time'/BcdAdapter(Bytes(7)),
@@ -612,7 +612,7 @@ class EF_OCI(CyclicEF):
self._construct = Struct('alpha_id'/Bytes(this._.total_len-27),
'len_of_bcd_contents'/Int8ub,
'ton_npi'/Int8ub,
'call_number'/BcdAdapter(Bytes(10)),
'call_number'/PaddedBcdAdapter(Rpad(Bytes(10))),
'cap_cfg2_record_id'/Int8ub,
'ext5_record_id'/Int8ub,
'date_and_time'/BcdAdapter(Bytes(7)),
@@ -1058,7 +1058,7 @@ class EF_OCSGL(LinFixedEF):
# TS 31.102 Section 4.4.11.2 (Rel 15)
class EF_5GS3GPPLOCI(TransparentEF):
def __init__(self, fid='4f01', sfid=0x01, name='EF.5GS3GPPLOCI', size=(20, 20),
desc='5S 3GP location information', **kwargs):
desc='5GS 3GPP location information', **kwargs):
super().__init__(fid, sfid=sfid, name=name, desc=desc, size=size, **kwargs)
upd_status_constr = Enum(
Byte, updated=0, not_updated=1, roaming_not_allowed=2)
@@ -1118,7 +1118,7 @@ class EF_Routing_Indicator(TransparentEF):
# responsibility of home network operator but BCD coding shall be used. If a network
# operator decides to assign less than 4 digits to Routing Indicator, the remaining digits
# shall be coded as "1111" to fill the 4 digits coding of Routing Indicator
self._construct = Struct('routing_indicator'/Rpad(BcdAdapter(Bytes(2)), 'f', 2),
self._construct = Struct('routing_indicator'/PaddedBcdAdapter(Rpad(Bytes(2))),
'rfu'/Bytes(2))
# TS 31.102 Section 4.4.11.13 (Rel 16)
@@ -1326,7 +1326,7 @@ class EF_5G_PROSE_UIR(TransparentEF):
pass
class FiveGDdnmfCtfAddrForUploading(BER_TLV_IE, tag=0x97):
pass
class ProSeConfigDataForUeToNetworkRelayUE(BER_TLV_IE, tag=0xa0,
class ProSeConfigDataForUsageInfoReporting(BER_TLV_IE, tag=0xa0,
nested=[EF_5G_PROSE_DD.ValidityTimer,
CollectionPeriod, ReportingWindow,
ReportingIndicators,
@@ -1336,7 +1336,7 @@ class EF_5G_PROSE_UIR(TransparentEF):
desc='5G ProSe configuration data for usage information reporting', **kwargs):
super().__init__(fid, sfid=sfid, name=name, desc=desc, **kwargs)
# contains TLV structure despite being TransparentEF, not BER-TLV ?!?
self._tlv = EF_5G_PROSE_UIR.ProSeConfigDataForUeToNetworkRelayUE
self._tlv = EF_5G_PROSE_UIR.ProSeConfigDataForUsageInfoReporting
# TS 31.102 Section 4.4.13.8 (Rel 18)
class EF_5G_PROSE_U2URU(TransparentEF):

View File

@@ -40,6 +40,7 @@ from osmocom.utils import *
from osmocom.construct import *
from pySim.utils import dec_iccid, enc_iccid, dec_imsi, enc_imsi, dec_plmn, enc_plmn, dec_xplmn_w_act
from pySim.utils import bytes_for_nibbles
from pySim.profile import CardProfile, CardProfileAddon
from pySim.filesystem import *
from pySim.ts_31_102_telecom import DF_PHONEBOOK, DF_MULTIMEDIA, DF_MCS, DF_V2X
@@ -151,7 +152,7 @@ class EF_ADN(LinFixedEF):
self._construct = Struct('alpha_id'/COptional(GsmOrUcs2Adapter(Rpad(Bytes(this._.total_len-14)))),
'len_of_bcd'/Int8ub,
'ton_npi'/TonNpi,
'dialing_nr'/ExtendedBcdAdapter(BcdAdapter(Rpad(Bytes(10)))),
'dialing_nr'/ExtendedBcdAdapter(PaddedBcdAdapter(Rpad(Bytes(10)))),
'cap_conf_id'/Int8ub,
ext_name/Int8ub)
@@ -192,11 +193,11 @@ class EF_MSISDN(LinFixedEF):
( 'ffffffffffffffffffffffffffffffffffffffff04b12143f5ffffffffffffffffff',
{"alpha_id": "", "len_of_bcd": 4, "ton_npi": {"ext": True, "type_of_number": "network_specific",
"numbering_plan_id": "isdn_e164"},
"dialing_nr": "12345f"}),
"dialing_nr": "12345"}),
( '456967656e65205275666e756d6d6572ffffffff0891947172199181f3ffffffffff',
{"alpha_id": "Eigene Rufnummer", "len_of_bcd": 8, "ton_npi": {"ext": True, "type_of_number": "international",
"numbering_plan_id": "isdn_e164"},
"dialing_nr": "4917279119183f"}),
"dialing_nr": "4917279119183"}),
]
# Ensure deprecated representations still work
@@ -214,7 +215,7 @@ class EF_MSISDN(LinFixedEF):
self._construct = Struct('alpha_id'/COptional(GsmOrUcs2Adapter(Rpad(Bytes(this._.total_len-14)))),
'len_of_bcd'/Int8ub,
'ton_npi'/TonNpi,
'dialing_nr'/ExtendedBcdAdapter(BcdAdapter(Rpad(Bytes(10)))),
'dialing_nr'/ExtendedBcdAdapter(PaddedBcdAdapter(Rpad(Bytes(10)))),
Padding(2, pattern=b'\xff'))
# Maintain compatibility with deprecated representations
@@ -239,11 +240,20 @@ class EF_MSISDN(LinFixedEF):
# TS 51.011 Section 10.5.6
class EF_SMSP(LinFixedEF):
# FIXME: re-encode fails / missing alpha_id at start of output
_test_decode = [
_test_de_encode = [
( '534d5343ffffffffffffffffffffffffe1ffffffffffffffffffffffff0891945197109099f9ffffff0000a9',
{ "alpha_id": "SMSC", "parameter_indicators": { "tp_dest_addr": False, "tp_sc_addr": True,
"tp_pid": True, "tp_dcs": True, "tp_vp": True },
"tp_dest_addr": { "length": 255, "ton_npi": { "ext": True, "type_of_number": "reserved_for_extension",
"numbering_plan_id": "reserved_for_extension" },
"call_number": "" },
"tp_sc_addr": { "length": 8, "ton_npi": { "ext": True, "type_of_number": "international",
"numbering_plan_id": "isdn_e164" },
"call_number": "4915790109999" },
"tp_pid": b"\x00", "tp_dcs": b"\x00", "tp_vp_minutes": 4320 } ),
( '454e6574776f726b73fffffffffffffff1ffffffffffffffffffffffffffffffffffffffffffffffff0000a7',
{ "alpha_id": "ENetworks", "parameter_indicators": { "tp_dest_addr": False, "tp_sc_addr": True,
"tp_pid": True, "tp_dcs": True, "tp_vp": True },
"tp_pid": True, "tp_dcs": True, "tp_vp": False },
"tp_dest_addr": { "length": 255, "ton_npi": { "ext": True, "type_of_number": "reserved_for_extension",
"numbering_plan_id": "reserved_for_extension" },
"call_number": "" },
@@ -251,6 +261,26 @@ class EF_SMSP(LinFixedEF):
"numbering_plan_id": "reserved_for_extension" },
"call_number": "" },
"tp_pid": b"\x00", "tp_dcs": b"\x00", "tp_vp_minutes": 1440 } ),
( 'fffffffffffffffffffffffffffffffffffffffffffffffffdffffffffffffffffffffffff07919403214365f7ffffffffffffff',
{ "alpha_id": "", "parameter_indicators": { "tp_dest_addr": False, "tp_sc_addr": True,
"tp_pid": False, "tp_dcs": False, "tp_vp": False },
"tp_dest_addr": { "length": 255, "ton_npi": { "ext": True, "type_of_number": "reserved_for_extension",
"numbering_plan_id": "reserved_for_extension" },
"call_number": "" },
"tp_sc_addr": { "length": 7, "ton_npi": { "ext": True, "type_of_number": "international",
"numbering_plan_id": "isdn_e164" },
"call_number": "49301234567" },
"tp_pid": b"\xff", "tp_dcs": b"\xff", "tp_vp_minutes": 635040 } ),
( 'fffffffffffffffffffffffffffffffffffffffffffffffffc0b919403214365f7ffffffff07919403214365f7ffffffffffffff',
{ "alpha_id": "", "parameter_indicators": { "tp_dest_addr": True, "tp_sc_addr": True,
"tp_pid": False, "tp_dcs": False, "tp_vp": False },
"tp_dest_addr": { "length": 11, "ton_npi": { "ext": True, "type_of_number": "international",
"numbering_plan_id": "isdn_e164" },
"call_number": "49301234567" },
"tp_sc_addr": { "length": 7, "ton_npi": { "ext": True, "type_of_number": "international",
"numbering_plan_id": "isdn_e164" },
"call_number": "49301234567" },
"tp_pid": b"\xff", "tp_dcs": b"\xff", "tp_vp_minutes": 635040 } ),
]
_test_no_pad = True
class ValidityPeriodAdapter(Adapter):
@@ -267,25 +297,50 @@ class EF_SMSP(LinFixedEF):
raise ValueError
def _encode(self, obj, context, path):
if obj <= 12*60:
return obj/5 - 1
return obj // 5 - 1
elif obj <= 24*60:
return 143 + ((obj - (12 * 60)) // 30)
elif obj <= 30 * 24 * 60:
return 166 + (obj / (24 * 60))
return 166 + (obj // (24 * 60))
elif obj <= 63 * 7 * 24 * 60:
return 192 + (obj // (7 * 24 * 60))
else:
raise ValueError
@staticmethod
def sc_addr_len(ctx):
"""Compute the length field for an address field (see also: 3GPP TS 24.011, section 8.2.5.2)."""
if not hasattr(ctx, 'call_number') or len(ctx.call_number) == 0:
return 0xff
else:
# octets required for the call_number + one octet for ton_npi
return bytes_for_nibbles(len(ctx.call_number)) + 1
@staticmethod
def dest_addr_len(ctx):
"""Compute the length field for an address field (see also: 3GPP TS 23.040, section 9.1.2.5)."""
if not hasattr(ctx, 'call_number') or len(ctx.call_number) == 0:
return 0xff
else:
# number of call_number digits
return len(ctx.call_number)
def __init__(self, fid='6f42', sfid=None, name='EF.SMSP', desc='Short message service parameters', **kwargs):
super().__init__(fid, sfid=sfid, name=name, desc=desc, rec_len=(28, None), **kwargs)
ScAddr = Struct('length'/Int8ub, 'ton_npi'/TonNpi, 'call_number'/BcdAdapter(Rpad(Bytes(10))))
self._construct = Struct('alpha_id'/COptional(GsmStringAdapter(Rpad(Bytes(this._.total_len-28)))),
'parameter_indicators'/InvertAdapter(FlagsEnum(Byte, tp_dest_addr=1, tp_sc_addr=2,
tp_pid=3, tp_dcs=4, tp_vp=5)),
'tp_dest_addr'/ScAddr,
ScAddr = Struct('length'/Rebuild(Int8ub, lambda ctx: EF_SMSP.sc_addr_len(ctx)),
'ton_npi'/TonNpi, 'call_number'/PaddedBcdAdapter(Rpad(Bytes(10))))
DestAddr = Struct('length'/Rebuild(Int8ub, lambda ctx: EF_SMSP.dest_addr_len(ctx)),
'ton_npi'/TonNpi, 'call_number'/PaddedBcdAdapter(Rpad(Bytes(10))))
self._construct = Struct('alpha_id'/COptional(GsmOrUcs2Adapter(Rpad(Bytes(this._.total_len-28)))),
'parameter_indicators'/InvertAdapter(BitStruct(
Const(7, BitsInteger(3)),
'tp_vp'/Flag,
'tp_dcs'/Flag,
'tp_pid'/Flag,
'tp_sc_addr'/Flag,
'tp_dest_addr'/Flag)),
'tp_dest_addr'/DestAddr,
'tp_sc_addr'/ScAddr,
'tp_pid'/Bytes(1),
'tp_dcs'/Bytes(1),
'tp_vp_minutes'/EF_SMSP.ValidityPeriodAdapter(Byte))
@@ -365,7 +420,7 @@ class DF_TELECOM(CardDF):
# TS 51.011 Section 10.3.1
class EF_LP(TransRecEF):
_test_de_encode = [
( "24", "24"),
( "24", ["24"] ),
]
def __init__(self, fid='6f05', sfid=None, name='EF.LP', size=(1, None), rec_len=1,
desc='Language Preference'):
@@ -422,8 +477,8 @@ class EF_IMSI(TransparentEF):
# TS 51.011 Section 10.3.4
class EF_PLMNsel(TransRecEF):
_test_de_encode = [
( "22F860", { "mcc": "228", "mnc": "06" } ),
( "330420", { "mcc": "334", "mnc": "020" } ),
( "22F860", [{ "mcc": "228", "mnc": "06" }] ),
( "330420", [{ "mcc": "334", "mnc": "020" }] ),
]
def __init__(self, fid='6f30', sfid=None, name='EF.PLMNsel', desc='PLMN selector',
size=(24, None), rec_len=3, **kwargs):
@@ -637,12 +692,12 @@ class EF_AD(TransparentEF):
# TS 51.011 Section 10.3.20 / 10.3.22
class EF_VGCS(TransRecEF):
_test_de_encode = [
( "92f9ffff", "299fffff" ),
( "92f9ffff", ["299"] ),
]
def __init__(self, fid='6fb1', sfid=None, name='EF.VGCS', size=(4, 200), rec_len=4,
desc='Voice Group Call Service', **kwargs):
super().__init__(fid, sfid=sfid, name=name, desc=desc, size=size, rec_len=rec_len, **kwargs)
self._construct = BcdAdapter(Bytes(4))
self._construct = PaddedBcdAdapter(Rpad(Bytes(4)))
# TS 51.011 Section 10.3.21 / 10.3.23
class EF_VGCSS(TransparentEF):
@@ -773,9 +828,9 @@ class EF_LOCIGPRS(TransparentEF):
# TS 51.011 Section 10.3.35..37
class EF_xPLMNwAcT(TransRecEF):
_test_de_encode = [
( '62F2104000', { "mcc": "262", "mnc": "01", "act": [ "E-UTRAN NB-S1", "E-UTRAN WB-S1" ] } ),
( '62F2108000', { "mcc": "262", "mnc": "01", "act": [ "UTRAN" ] } ),
( '62F220488C', { "mcc": "262", "mnc": "02", "act": ['E-UTRAN NB-S1', 'E-UTRAN WB-S1', 'EC-GSM-IoT', 'GSM', 'NG-RAN'] } ),
( '62F2104000', [{ "mcc": "262", "mnc": "01", "act": [ "E-UTRAN NB-S1", "E-UTRAN WB-S1" ] }] ),
( '62F2108000', [{ "mcc": "262", "mnc": "01", "act": [ "UTRAN" ] }] ),
( '62F220488C', [{ "mcc": "262", "mnc": "02", "act": ['E-UTRAN NB-S1', 'E-UTRAN WB-S1', 'EC-GSM-IoT', 'GSM', 'NG-RAN'] }] ),
]
def __init__(self, fid='1234', sfid=None, name=None, desc=None, size=(40, None), rec_len=5, **kwargs):
super().__init__(fid, sfid=sfid, name=name, desc=desc, size=size, rec_len=rec_len, **kwargs)
@@ -1010,9 +1065,10 @@ class EF_ICCID(TransparentEF):
# TS 102 221 Section 13.3 / TS 31.101 Section 13 / TS 51.011 Section 10.1.2
class EF_PL(TransRecEF):
_test_de_encode = [
( '6465', "de" ),
( '656e', "en" ),
( 'ffff', None ),
( '6465', ["de"] ),
( '656e', ["en"] ),
( 'ffff', [None] ),
( '656e64657275ffffffff', ["en", "de", "ru", None, None] ),
]
def __init__(self, fid='2f05', sfid=0x05, name='EF.PL', desc='Preferred Languages'):
@@ -1093,8 +1149,8 @@ class DF_GSM(CardDF):
EF_MBI(),
EF_MWIS(),
EF_CFIS(),
EF_EXT('6fc8', None, 'EF.EXT6', desc='Externsion6 (MBDN)'),
EF_EXT('6fcc', None, 'EF.EXT7', desc='Externsion7 (CFIS)'),
EF_EXT('6fc8', None, 'EF.EXT6', desc='Extension6 (MBDN)'),
EF_EXT('6fcc', None, 'EF.EXT7', desc='Extension7 (CFIS)'),
EF_SPDI(),
EF_MMSN(),
EF_EXT('6fcf', None, 'EF.EXT8', desc='Extension8 (MMSN)'),

View File

@@ -139,7 +139,6 @@ def enc_plmn(mcc: Hexstr, mnc: Hexstr) -> Hexstr:
def dec_plmn(threehexbytes: Hexstr) -> dict:
res = {'mcc': "0", 'mnc': "0"}
dec_mcc_from_plmn_str(threehexbytes)
res['mcc'] = dec_mcc_from_plmn_str(threehexbytes)
res['mnc'] = dec_mnc_from_plmn_str(threehexbytes)
return res
@@ -526,6 +525,13 @@ def expand_hex(hexstring, length):
# no change
return hexstring
def bytes_for_nibbles(num_nibbles: int) -> int:
"""compute the number of bytes needed to store the given number of nibbles."""
n_bytes = num_nibbles // 2
if num_nibbles & 1:
n_bytes += 1
return n_bytes
def boxed_heading_str(heading, width=80):
"""Generate a string that contains a boxed heading."""
@@ -624,15 +630,17 @@ def decomposeATR(atr_txt):
Returns:
dictionary of field and values
>>> decomposeATR("3B A7 00 40 18 80 65 A2 08 01 01 52")
{ 'T0': {'value': 167},
'TB': {1: {'value': 0}},
'TC': {2: {'value': 24}},
'TD': {1: {'value': 64}},
'TS': {'value': 59},
'atr': [59, 167, 0, 64, 24, 128, 101, 162, 8, 1, 1, 82],
'hb': {'value': [128, 101, 162, 8, 1, 1, 82]},
'hbn': 7}
Example::
>>> decomposeATR("3B A7 00 40 18 80 65 A2 08 01 01 52")
{ 'T0': {'value': 167},
'TB': {1: {'value': 0}},
'TC': {2: {'value': 24}},
'TD': {1: {'value': 64}},
'TS': {'value': 59},
'atr': [59, 167, 0, 64, 24, 128, 101, 162, 8, 1, 1, 82],
'hb': {'value': [128, 101, 162, 8, 1, 1, 82]},
'hbn': 7}
"""
ATR_PROTOCOL_TYPE_T0 = 0
atr_txt = normalizeATR(atr_txt)
@@ -902,7 +910,8 @@ class DataObjectCollection:
def encode(self, decoded) -> bytes:
res = bytearray()
for i in decoded:
obj = self.members_by_name(i[0])
name = i[0]
obj = self.members_by_name[name]
res.append(obj.to_tlv())
return res

View File

@@ -5,7 +5,7 @@ cmd2>=2.6.2,<3.0
jsonpath-ng
construct>=2.10.70
bidict
pyosmocom>=0.0.9
pyosmocom>=0.0.12
pyyaml>=5.1
termcolor
colorlog
@@ -15,3 +15,4 @@ git+https://github.com/osmocom/asn1tools
packaging
git+https://github.com/hologram-io/smpp.pdu
smpp.twisted3 @ git+https://github.com/jookies/smpp.twisted
smpplib

View File

@@ -21,11 +21,11 @@ setup(
"pyscard",
"pyserial",
"pytlv",
"cmd2 >= 1.5.0",
"cmd2 >= 1.5.0, < 3.0",
"jsonpath-ng",
"construct >= 2.10.70",
"bidict",
"pyosmocom >= 0.0.9",
"pyosmocom >= 0.0.12",
"pyyaml >= 5.1",
"termcolor",
"colorlog",
@@ -55,6 +55,10 @@ setup(
"service-identity",
"pyopenssl",
"requests",
"smpplib",
],
"CardKeyProviderPgsql": [
"psycopg2-binary",
]
},
)

View File

@@ -2200,9 +2200,9 @@ update_record 6 fe0112ffb53e96e5ff99731d51ad7beafd0e23ffffffffffffffffffffffffff
update_record 7 fe02101da012f436d06824ecdd15050419ff9affffffffffffffffffffffffffffffff
update_record 8 fe02116929a373388ac904aff57ff57f6b3431ffffffffffffffffffffffffffffffff
update_record 9 fe0212a99245a5dc814e2f4c1aa908e9946e03ffffffffffffffffffffffffffffffff
update_record 10 fe0310521312c05a9aea93d70d44405172a580ffffffffffffffffffffffffffffffff
update_record 11 fe0311a9e45c72d45abde7db74261ee0c11b1bffffffffffffffffffffffffffffffff
update_record 12 fe0312867ba36b5873d60ea8b2cdcf3c0ddddaffffffffffffffffffffffffffffffff
update_record 10 fe03601111111111111111111111111111111111111111111111111111111111111111
update_record 11 fe03612222222222222222222222222222222222222222222222222222222222222222
update_record 12 fe03623333333333333333333333333333333333333333333333333333333333333333
#
################################################################################
# MF/DF.SYSTEM/EF.SIM_AUTH_COUNTER #

View File

@@ -1,4 +1,4 @@
Using PC/SC reader interface
INFO: Using PC/SC reader interface
Reading ...
Autodetected card type: Fairwaves-SIM
ICCID: 8988219000000117833
@@ -6,6 +6,7 @@ IMSI: 001010000000111
GID1: ffffffffffffffff
GID2: ffffffffffffffff
SMSP: e1ffffffffffffffffffffffff0581005155f5ffffffffffff000000ffffffffffffffffffffffffffff
SMSC: 0015555
SPN: Fairwaves
Show in HPLMN: False
Hide in OPLMN: False

View File

@@ -1,4 +1,4 @@
Using PC/SC reader interface
INFO: Using PC/SC reader interface
Reading ...
Autodetected card type: Wavemobile-SIM
ICCID: 89445310150011013678
@@ -6,6 +6,7 @@ IMSI: 001010000000102
GID1: Can't read file -- SW match failed! Expected 9000 and got 6a82.
GID2: Can't read file -- SW match failed! Expected 9000 and got 6a82.
SMSP: e1ffffffffffffffffffffffff0581005155f5ffffffffffff000000ffffffffffffffffffffffffffff
SMSC: 0015555
SPN: wavemobile
Show in HPLMN: False
Hide in OPLMN: False

View File

@@ -1,4 +1,4 @@
Using PC/SC reader interface
INFO: Using PC/SC reader interface
Reading ...
Autodetected card type: fakemagicsim
ICCID: 1122334455667788990
@@ -6,6 +6,7 @@ IMSI: 001010000000102
GID1: Can't read file -- SW match failed! Expected 9000 and got 9404.
GID2: Can't read file -- SW match failed! Expected 9000 and got 9404.
SMSP: ffffffffffffffffffffffffe1ffffffffffffffffffffffff0581005155f5ffffffffffff000000
SMSC: 0015555
SPN: Magic
Show in HPLMN: True
Hide in OPLMN: False

View File

@@ -2,7 +2,7 @@
# Utility to verify the functionality of pySim-prog.py
#
# (C) 2018 by Sysmocom s.f.m.c. GmbH
# (C) 2018 by sysmocom - s.f.m.c. GmbH
# All Rights Reserved
#
# Author: Philipp Maier

View File

@@ -1,4 +1,4 @@
Using PC/SC reader interface
INFO: Using PC/SC reader interface
Reading ...
Autodetected card type: sysmoISIM-SJA2
ICCID: 8988211000000467343
@@ -6,6 +6,7 @@ IMSI: 001010000000102
GID1: ffffffffffffffffffff
GID2: ffffffffffffffffffff
SMSP: ffffffffffffffffffffffffffffffffffffffffffffffffe1ffffffffffffffffffffffff0581005155f5ffffffffffff000000
SMSC: 0015555
SPN: Magic
Show in HPLMN: True
Hide in OPLMN: True

View File

@@ -1,4 +1,4 @@
Using PC/SC reader interface
INFO: Using PC/SC reader interface
Reading ...
Autodetected card type: sysmoISIM-SJA5
ICCID: 8949440000001155314
@@ -6,6 +6,7 @@ IMSI: 001010000000102
GID1: ffffffffffffffffffff
GID2: ffffffffffffffffffff
SMSP: ffffffffffffffffffffffffffffffffffffffffffffffffe1ffffffffffffffffffffffff0581005155f5ffffffffffff000000
SMSC: 0015555
SPN: Magic
Show in HPLMN: True
Hide in OPLMN: True

View File

@@ -1,4 +1,4 @@
Using PC/SC reader interface
INFO: Using PC/SC reader interface
Reading ...
Autodetected card type: sysmoUSIM-SJS1
ICCID: 8988211320300000028
@@ -6,6 +6,7 @@ IMSI: 001010000000102
GID1: ffffffffffffffffffff
GID2: ffffffffffffffffffff
SMSP: ffffffffffffffffffffffffffffffffffffffffffffffffe1ffffffffffffffffffffffff0581005155f5ffffffffffff000000
SMSC: 0015555
SPN: Magic
Show in HPLMN: True
Hide in OPLMN: True

View File

@@ -1,4 +1,4 @@
Using PC/SC reader interface
INFO: Using PC/SC reader interface
Reading ...
Autodetected card type: sysmosim-gr1
ICCID: 2222334455667788990
@@ -6,6 +6,7 @@ IMSI: 001010000000102
GID1: Can't read file -- SW match failed! Expected 9000 and got 9404.
GID2: Can't read file -- SW match failed! Expected 9000 and got 9404.
SMSP: ffffffffffffffffffffffffe1ffffffffffffffffffffffff0581005155f5ffffffffffff000000
SMSC: 0015555
SPN: Not available
Show in HPLMN: False
Hide in OPLMN: False

View File

@@ -15,7 +15,7 @@
},
{
"profile_info": {
"iccid": "8949449999999990031f",
"iccid": "8949449999999990031",
"isdp_aid": "a0000005591010ffffffff8900001200",
"profile_state": "disabled",
"service_provider_name": "OsmocomSPN",

View File

@@ -23,7 +23,7 @@ import os
import json
from utils import *
# This testcase requires a sysmoEUICC1-C2T with the test prfile TS48V1-B-UNIQUE (ICCID 8949449999999990031f)
# This testcase requires a sysmoEUICC1-C2T with the test prfile TS48V1-B-UNIQUE (ICCID 8949449999999990031)
# installed, and in disabled state. Also the profile must be installed in such a way that notifications are
# generated when the profile is disabled or enabled (ProfileMetadata)
@@ -56,7 +56,7 @@ class test_case(UnittestUtils):
self.runPySimShell(cardname, "test_enable_disable_profile.script")
self.assertEqualFiles("enable_disable_profile.tmp")
def test_enable_disable_profile(self):
def test_set_nickname(self):
cardname = 'sysmoEUICC1-C2T'
self.runPySimShell(cardname, "test_set_nickname.script")

View File

@@ -3,6 +3,9 @@ set echo true
select ADF.ISD-R
# Ensure that the test-profile we intend to test with is actually enabled
enable_profile --iccid 89000123456789012341
# by ICCID (pre-installed test profile on sysmoEUICC1-C2T)
disable_profile --iccid 89000123456789012341 > enable_disable_profile.tmp
enable_profile --iccid 89000123456789012341 >> enable_disable_profile.tmp

View File

@@ -3,6 +3,11 @@ set echo true
select ADF.ISD-R
# Generate two (additional) notifications by quickly enabeling the test profile
enable_profile --iccid 8949449999999990031f
# Ensure that the test-profile is actually enabled. (In case te test-profile
# was disabled, a notification may be generated. The testcase should tolerate
# that)
enable_profile --iccid 89000123456789012341
# Generate two (additional) notifications by quickly enabeling the test profile
enable_profile --iccid 8949449999999990031
enable_profile --iccid 89000123456789012341

View File

@@ -1,5 +1,10 @@
set debug true
set echo true
# The output of get_profiles_info will also include the "profile_state", which
# can be either "enabled" or "disabled". Ensure that the correct profile is
# enabled.
enable_profile --iccid 89000123456789012341
select ADF.ISD-R
get_profiles_info > get_profiles_info.tmp

View File

@@ -19,7 +19,7 @@
"type_of_number": "reserved_for_extension",
"numbering_plan_id": "reserved_for_extension"
},
"dialing_nr": "123456",
"dialing_nr": "1234567",
"cap_conf_id": 42,
"ext4_record_id": 23
},
@@ -67,7 +67,7 @@
"type_of_number": "reserved_for_extension",
"numbering_plan_id": "reserved_for_extension"
},
"dialing_nr": "123456",
"dialing_nr": "1234567",
"cap_conf_id": 42,
"ext4_record_id": 23
},
@@ -127,7 +127,7 @@
"type_of_number": "reserved_for_extension",
"numbering_plan_id": "reserved_for_extension"
},
"dialing_nr": "123456",
"dialing_nr": "1234567",
"cap_conf_id": 42,
"ext4_record_id": 23
}
@@ -140,7 +140,7 @@
"type_of_number": "reserved_for_extension",
"numbering_plan_id": "reserved_for_extension"
},
"dialing_nr": "123456",
"dialing_nr": "1234567",
"cap_conf_id": 42,
"ext4_record_id": 23
}

View File

@@ -0,0 +1,216 @@
#!/bin/bash
# Utility to verify the functionality of pySim-trace.py
#
# (C) 2026 by sysmocom - s.f.m.c. GmbH
# All Rights Reserved
#
# Author: Philipp Maier
#
# 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 <http://www.gnu.org/licenses/>.
PYSIM_SHELL=./pySim-shell.py
PYSIM_SHELL_LOG=./pySim-shell.log
PYSIM_SMPP2SIM=./pySim-smpp2sim.py
PYSIM_SMPP2SIM_LOG=./pySim-smpp2sim.log
PYSIM_SMPP2SIM_PORT=2775
PYSIM_SMPP2SIM_TIMEOUT=10
PYSIM_SMPPOTATOOL=./contrib/smpp-ota-tool.py
PYSIM_SMPPOTATOOL_LOG=./smpp-ota-tool.log
function dump_logs {
echo ""
echo "$PYSIM_SMPPOTATOOL_LOG"
echo "------------8<------------"
cat $PYSIM_SMPPOTATOOL_LOG
echo "------------8<------------"
echo ""
echo "$PYSIM_SMPP2SIM_LOG"
echo "------------8<------------"
cat $PYSIM_SMPP2SIM_LOG
echo "------------8<------------"
}
function send_test_request {
echo ""
echo "Sending request to SMPP server:"
C_APDU=$1
R_APDU_EXPECTED=$2
echo "Sending: $C_APDU"
COMMANDLINE="$PYSIM_SMPPOTATOOL --verbose --port $PYSIM_SMPP2SIM_PORT --kic $KIC --kid $KID --kic-idx $KEY_INDEX --kid-idx $KEY_INDEX --algo-crypt $ALGO_CRYPT --algo-auth $ALGO_AUTH --tar $TAR --apdu $C_APDU"
echo "Commandline: $COMMANDLINE"
R_APDU=`$COMMANDLINE 2> $PYSIM_SMPPOTATOOL_LOG`
if [ $? -ne 0 ]; then
echo "Unable to send request! -- failed!"
dump_logs
exit 1
fi
echo ""
echo "Got response from SMPP server:"
echo "Sent: $C_APDU"
echo "Received: $R_APDU"
echo "Expected: $R_APDU_EXPECTED"
if [ "$R_APDU" != "$R_APDU_EXPECTED" ]; then
echo "Response does not match the expected response! -- failed!"
dump_logs
exit 1
fi
echo "Response matches the expected response -- success!"
}
function start_smpp_server {
PCSC_READER=$1
echo ""
echo "Starting SMPP server:"
# Start the SMPP server
COMMANDLINE="$PYSIM_SMPP2SIM -p $PCSC_READER --smpp-bind-port $PYSIM_SMPP2SIM_PORT --apdu-trace"
echo "Commandline: $COMMANDLINE"
$COMMANDLINE > $PYSIM_SMPP2SIM_LOG 2>&1 &
PYSIM_SMPP2SIM_PID=$!
trap 'kill $PYSIM_SMPP2SIM_PID' EXIT
echo "SMPP server started (PID=$PYSIM_SMPP2SIM_PID)"
# Wait until the SMPP server is reachable
RC=1
RETRY_COUNT=0
while [ $RC -ne 0 ]; do
nc -z localhost $PYSIM_SMPP2SIM_PORT
RC=$?
((RETRY_COUNT++))
if [ $RETRY_COUNT -gt $PYSIM_SMPP2SIM_TIMEOUT ]; then
echo "SMPP server not reachable (port=$PYSIM_SMPP2SIM_PORT) -- abort"
dump_logs
exit 1
fi
sleep 1
done
echo "SMPP server reachable (port=$PYSIM_SMPP2SIM_PORT)"
}
function stop_smpp_server {
echo ""
echo "Stopping SMPP server:"
kill $PYSIM_SMPP2SIM_PID
echo "SMPP server stopped (PID=$PYSIM_SMPP2SIM_PID)"
trap EXIT
}
function find_card_by_iccid_or_eid {
ICCID=$1
EID=$2
echo ""
echo "Searching for card:"
echo "ICCID: \"$ICCID\""
if [ -n "$EID" ]; then
echo "EID: \"$EID\""
fi
# Determine number of available PCSC readers
PCSC_READER_COUNT=`pcsc_scan -rn | wc -l`
# In case an EID is set, search for a card with that EID first
if [ -n "$EID" ]; then
for PCSC_READER in $(seq 0 $(($PCSC_READER_COUNT-1))); do
echo "probing card (eID) in reader $PCSC_READER ..."
RESULT_JSON=`$PYSIM_SHELL -p $PCSC_READER --noprompt -e "select ADF.ISD-R" -e "get_eid" 2> /dev/null | tail -3`
echo $RESULT_JSON | grep $EID > /dev/null
if [ $? -eq 0 ]; then
echo "Found card (eID) in reader $PCSC_READER"
return $PCSC_READER
fi
done
fi
# Search for card with the given ICCID
if [ -z "$ICCID" ]; then
echo "invalid ICCID, zero length ICCID is not allowed! -- abort"
exit 1
fi
for PCSC_READER in $(seq 0 $(($PCSC_READER_COUNT-1))); do
echo "probing card (ICCID) in reader $PCSC_READER ..."
RESULT_JSON=`$PYSIM_SHELL -p $PCSC_READER --noprompt -e "select EF.ICCID" -e "read_binary_decoded" 2> /dev/null | tail -3`
echo $RESULT_JSON | grep $ICCID > /dev/null
if [ $? -eq 0 ]; then
echo "Found card (by ICCID) in reader $PCSC_READER"
return $PCSC_READER
fi
done
echo "Card not found -- abort"
exit 1
}
function enable_profile {
PCSC_READER=$1
ICCID=$2
EID=$3
if [ -z "$EID" ]; then
# This is no eUICC, nothing to enable
return 0
fi
# Check if the profile is already enabled
RESULT_JSON=`$PYSIM_SHELL -p $PCSC_READER --noprompt -e "select EF.ICCID" -e "read_binary_decoded" 2> /dev/null | tail -3`
ICCID_ENABLED=`echo $RESULT_JSON | jq -r '.iccid'`
if [ $ICCID != $ICCID_ENABLED ]; then
# Disable the currentle enabled profile
echo ""
echo "Disabeling currently enabled profile:"
echo "ICCID: \"$ICCID\""
RESULT_JSON=`$PYSIM_SHELL -p $PCSC_READER --noprompt -e "select ADF.ISD-R" -e "disable_profile --iccid $ICCID_ENABLED" 2> /dev/null | tail -3`
echo $RESULT_JSON | grep "ok" > /dev/null
if [ $? -ne 0 ]; then
echo "unable to disable profile with \"$ICCID_ENABLED\""
exit 1
fi
echo "profile disabled"
# Enable the profile we intend to test with
echo ""
echo "Enabeling profile:"
echo "ICCID: \"$ICCID\""
RESULT_JSON=`$PYSIM_SHELL -p $PCSC_READER --noprompt -e "select ADF.ISD-R" -e "enable_profile --iccid $ICCID" 2> /dev/null | tail -3`
echo $RESULT_JSON | grep "ok\|profileNotInDisabledState" > /dev/null
if [ $? -ne 0 ]; then
echo "unable to enable profile with \"$ICCID\""
exit 1
fi
echo "profile enabled"
fi
}
export PYTHONPATH=./
echo "pySim-smpp2sim_test - a test program to test pySim-smpp2sim.py"
echo "=============================================================="
TESTCASE_DIR=`dirname $0`
for TEST_CONFIG_FILE in $TESTCASE_DIR/testcase_*.cfg ; do
echo ""
echo "running testcase: $TEST_CONFIG_FILE"
. $TEST_CONFIG_FILE
find_card_by_iccid_or_eid $ICCID $EID
PCSC_READER=$?
enable_profile $PCSC_READER $ICCID $EID
start_smpp_server $PCSC_READER
send_test_request $APDU "$EXPECTED_RESPONSE"
stop_smpp_server
echo ""
echo "testcase ok"
echo "--------------------------------------------------------------"
done
echo "done."

View File

@@ -0,0 +1,17 @@
# Preparation:
# This testcase executes against a sysmoISIM-SJA5 card. For the testcase, the
# key configuration on the card may be used as it is.
# Card parameter:
ICCID="8949440000001155314" # <-- change to the ICCID of your card!
EID=""
KIC='51D4FC44BCBA7C4589DFADA3297720AF' # <-- change to the KIC1 of your card!
KID='0449699C472CE71E2FB7B56245EF7684' # <-- change to the KID1 of your card!
KEY_INDEX=1
ALGO_CRYPT=triple_des_cbc2
ALGO_AUTH=triple_des_cbc2
TAR='B00010'
# Testcase: Send OTA-SMS that selects DF.GSM and returns the select response
APDU='A0A40000027F20A0C0000016'
EXPECTED_RESPONSE='0000ffff7f2002000000000009b106350400838a838a 9000'

View File

@@ -0,0 +1,19 @@
# Preparation:
# This testcase executes against a sysmoEUICC1-C2T, which is equipped with the
# TS48V1-B-UNIQUE test profile from https://test.rsp.sysmocom.de/ (Activation
# code: 1$smdpp.test.rsp.sysmocom.de$TS48V1-B-UNIQUE). This testprofile must be
# present on the eUICC before this testcase can be executed.
# Card parameter:
ICCID="8949449999999990031"
EID="89049044900000000000000000102355" # <-- change to the EID of your card!
KIC='66778899aabbccdd1122334455eeff10'
KID='112233445566778899aabbccddeeff10'
KEY_INDEX=2
ALGO_CRYPT=aes_cbc
ALGO_AUTH=aes_cmac
TAR='b00120'
# Testcase: Send OTA-SMS that selects DF.ICCID and returns the select response
APDU='00a40004022fe200C000001d'
EXPECTED_RESPONSE='621b8202412183022fe2a503d001408a01058b032f06038002000a8800 9000'

View File

@@ -0,0 +1,28 @@
# Preparation:
# This testcase executes against a sysmoISIM-SJA5 card. Since this card model is
# shipped with a classic DES key configuration, it is necessary to provision
# AES128 test keys before this testcase may be executed. The the following
# pySim-shell command sequence may be used:
#
# verify_adm 34173960 # <-- change to the ADM key of your card!
# select /DF.SYSTEM/EF.0348_KEY
# update_record 10 fe03601111111111111111111111111111111111111111111111111111111111111111
# update_record 11 fe03612222222222222222222222222222222222222222222222222222222222222222
# update_record 12 fe03623333333333333333333333333333333333333333333333333333333333333333
#
# This overwrites one of the already existing 3DES SCP02 key (KVN 47) and replaces it
# with an AES256 SCP80 key (KVN 3).
# Card parameter:
ICCID="8949440000001155314" # <-- change to the ICCID of your card!
EID=""
KIC='1111111111111111111111111111111111111111111111111111111111111111'
KID='2222222222222222222222222222222222222222222222222222222222222222'
KEY_INDEX=3
ALGO_CRYPT=aes_cbc
ALGO_AUTH=aes_cmac
TAR='B00010'
# Testcase: Send OTA-SMS that selects DF.GSM and returns the select response
APDU='A0A40000027F20A0C0000016'
EXPECTED_RESPONSE='0000ffff7f2002000000000009b106350400838a838a 9000'

View File

@@ -2,7 +2,7 @@
# Utility to verify the functionality of pySim-trace.py
#
# (C) 2023 by Sysmocom s.f.m.c. GmbH
# (C) 2023 by sysmocom - s.f.m.c. GmbH
# All Rights Reserved
#
# Author: Philipp Maier

View File

@@ -143,7 +143,7 @@ CardReset(3b9f96801f878031e073fe211b674a4c753034054ba9)
===============================
00 SEARCH RECORD MF/ADF.USIM/EF.SMSP 01 9000 {"cmd": {"file": "currently_selected_ef", "mode": "forward_search", "record_number": 1, "search_string": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"}, "rsp": {"body": [2], "sw": "9000"}}
===============================
00 READ RECORD MF/ADF.USIM/EF.SMSP 01 9000 {"alpha_id": "", "parameter_indicators": {"tp_dest_addr": false, "tp_sc_addr": true, "tp_pid": true, "tp_dcs": true, "tp_vp": true}, "tp_dest_addr": {"length": 255, "ton_npi": {"ext": true, "type_of_number": "reserved_for_extension", "numbering_plan_id": "reserved_for_extension"}, "call_number": ""}, "tp_sc_addr": {"length": 5, "ton_npi": {"ext": true, "type_of_number": "unknown", "numbering_plan_id": "isdn_e164"}, "call_number": "0015555f"}, "tp_pid": "00", "tp_dcs": "00", "tp_vp_minutes": 5}
00 READ RECORD MF/ADF.USIM/EF.SMSP 01 9000 {"alpha_id": "", "parameter_indicators": {"tp_vp": true, "tp_dcs": true, "tp_pid": true, "tp_sc_addr": true, "tp_dest_addr": false}, "tp_dest_addr": {"length": 255, "ton_npi": {"ext": true, "type_of_number": "reserved_for_extension", "numbering_plan_id": "reserved_for_extension"}, "call_number": ""}, "tp_sc_addr": {"length": 5, "ton_npi": {"ext": true, "type_of_number": "unknown", "numbering_plan_id": "isdn_e164"}, "call_number": "0015555"}, "tp_pid": "00", "tp_dcs": "00", "tp_vp_minutes": 5}
===============================
00 SEARCH RECORD MF/ADF.USIM/EF.SMS 01 9000 {"cmd": {"file": "currently_selected_ef", "mode": "forward_search", "record_number": 1, "search_string": "00ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"}, "rsp": {"body": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30], "sw": "9000"}}
===============================

1
tests/unittests/smdpp_data Symbolic link
View File

@@ -0,0 +1 @@
../../smdpp-data

View File

@@ -0,0 +1,461 @@
#!/usr/bin/env python3
# (C) 2025 by sysmocom - s.f.m.c. GmbH <info@sysmocom.de>
#
# Author: Neels Hofmeyr
#
# 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 <http://www.gnu.org/licenses/>.
import io
import sys
import unittest
import io
from importlib import resources
from osmocom.utils import hexstr
from pySim.esim.saip import ProfileElementSequence
import pySim.esim.saip.personalization as p13n
import smdpp_data.upp
import xo
update_expected_output = False
def valstr(val):
if isinstance(val, io.BytesIO):
val = val.getvalue()
if isinstance(val, bytearray):
val = bytes(val)
return f'{val!r}'
def valtypestr(val):
if isinstance(val, dict):
types = []
for v in val.values():
types.append(f'{type(v).__name__}')
val_type = '{' + ', '.join(types) + '}'
else:
val_type = f'{type(val).__name__}'
return f'{valstr(val)}:{val_type}'
class ConfigurableParameterTest(unittest.TestCase):
def test_parameters(self):
upp_fnames = (
'TS48v5_SAIP2.1A_NoBERTLV.der',
'TS48v5_SAIP2.3_BERTLV_SUCI.der',
'TS48v5_SAIP2.1B_NoBERTLV.der',
'TS48v5_SAIP2.3_NoBERTLV.der',
)
class Paramtest:
def __init__(self, param_cls, val, expect_val, expect_clean_val=None):
self.param_cls = param_cls
self.val = val
self.expect_clean_val = expect_clean_val
self.expect_val = expect_val
param_tests = [
Paramtest(param_cls=p13n.Imsi, val='123456',
expect_clean_val=str('123456'),
expect_val={'IMSI': hexstr('123456'),
'IMSI-ACC': '0040'}),
Paramtest(param_cls=p13n.Imsi, val=int(123456),
expect_val={'IMSI': hexstr('123456'),
'IMSI-ACC': '0040'}),
Paramtest(param_cls=p13n.Imsi, val='123456789012345',
expect_clean_val=str('123456789012345'),
expect_val={'IMSI': hexstr('123456789012345'),
'IMSI-ACC': '0020'}),
Paramtest(param_cls=p13n.Imsi, val=int(123456789012345),
expect_val={'IMSI': hexstr('123456789012345'),
'IMSI-ACC': '0020'}),
Paramtest(param_cls=p13n.Puk1,
val='12345678',
expect_clean_val=b'12345678',
expect_val='12345678'),
Paramtest(param_cls=p13n.Puk1,
val=int(12345678),
expect_clean_val=b'12345678',
expect_val='12345678'),
Paramtest(param_cls=p13n.Puk2,
val='12345678',
expect_clean_val=b'12345678',
expect_val='12345678'),
Paramtest(param_cls=p13n.Pin1,
val='1234',
expect_clean_val=b'1234\xff\xff\xff\xff',
expect_val='1234'),
Paramtest(param_cls=p13n.Pin1,
val='123456',
expect_clean_val=b'123456\xff\xff',
expect_val='123456'),
Paramtest(param_cls=p13n.Pin1,
val='12345678',
expect_clean_val=b'12345678',
expect_val='12345678'),
Paramtest(param_cls=p13n.Pin1,
val=int(1234),
expect_clean_val=b'1234\xff\xff\xff\xff',
expect_val='1234'),
Paramtest(param_cls=p13n.Pin1,
val=int(123456),
expect_clean_val=b'123456\xff\xff',
expect_val='123456'),
Paramtest(param_cls=p13n.Pin1,
val=int(12345678),
expect_clean_val=b'12345678',
expect_val='12345678'),
Paramtest(param_cls=p13n.Adm1,
val='1234',
expect_clean_val=b'1234\xff\xff\xff\xff',
expect_val='1234'),
Paramtest(param_cls=p13n.Adm1,
val='123456',
expect_clean_val=b'123456\xff\xff',
expect_val='123456'),
Paramtest(param_cls=p13n.Adm1,
val='12345678',
expect_clean_val=b'12345678',
expect_val='12345678'),
Paramtest(param_cls=p13n.Adm1,
val=int(123456),
expect_clean_val=b'123456\xff\xff',
expect_val='123456'),
Paramtest(param_cls=p13n.AlgorithmID,
val='Milenage',
expect_clean_val=1,
expect_val='Milenage'),
Paramtest(param_cls=p13n.AlgorithmID,
val='TUAK',
expect_clean_val=2,
expect_val='TUAK'),
Paramtest(param_cls=p13n.AlgorithmID,
val='usim-test',
expect_clean_val=3,
expect_val='usim-test'),
Paramtest(param_cls=p13n.AlgorithmID,
val=1,
expect_clean_val=1,
expect_val='Milenage'),
Paramtest(param_cls=p13n.AlgorithmID,
val=2,
expect_clean_val=2,
expect_val='TUAK'),
Paramtest(param_cls=p13n.AlgorithmID,
val=3,
expect_clean_val=3,
expect_val='usim-test'),
Paramtest(param_cls=p13n.K,
val='01020304050607080910111213141516',
expect_clean_val=b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16',
expect_val='01020304050607080910111213141516'),
Paramtest(param_cls=p13n.K,
val=b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16',
expect_clean_val=b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16',
expect_val='01020304050607080910111213141516'),
Paramtest(param_cls=p13n.K,
val=bytearray(b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16'),
expect_clean_val=b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16',
expect_val='01020304050607080910111213141516'),
Paramtest(param_cls=p13n.K,
val=io.BytesIO(b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16'),
expect_clean_val=b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16',
expect_val='01020304050607080910111213141516'),
Paramtest(param_cls=p13n.K,
val=int(11020304050607080910111213141516),
expect_clean_val=b'\x11\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16',
expect_val='11020304050607080910111213141516'),
Paramtest(param_cls=p13n.Opc,
val='01020304050607080910111213141516',
expect_clean_val=b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16',
expect_val='01020304050607080910111213141516'),
Paramtest(param_cls=p13n.Opc,
val=b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16',
expect_clean_val=b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16',
expect_val='01020304050607080910111213141516'),
Paramtest(param_cls=p13n.Opc,
val=bytearray(b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16'),
expect_clean_val=b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16',
expect_val='01020304050607080910111213141516'),
Paramtest(param_cls=p13n.Opc,
val=io.BytesIO(b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16'),
expect_clean_val=b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16',
expect_val='01020304050607080910111213141516'),
Paramtest(param_cls=p13n.SmspTpScAddr,
val='+1234567',
expect_clean_val=(True, '1234567'),
expect_val='+1234567'),
Paramtest(param_cls=p13n.SmspTpScAddr,
val=1234567,
expect_clean_val=(False, '1234567'),
expect_val='1234567'),
Paramtest(param_cls=p13n.TuakNumberOfKeccak,
val='123',
expect_clean_val=123,
expect_val='123'),
Paramtest(param_cls=p13n.TuakNumberOfKeccak,
val=123,
expect_clean_val=123,
expect_val='123'),
Paramtest(param_cls=p13n.MilenageRotationConstants,
val='0a 0b 0c 01 02',
expect_clean_val=b'\x0a\x0b\x0c\x01\x02',
expect_val='0a0b0c0102'),
Paramtest(param_cls=p13n.MilenageRotationConstants,
val=b'\x0a\x0b\x0c\x01\x02',
expect_clean_val=b'\x0a\x0b\x0c\x01\x02',
expect_val='0a0b0c0102'),
Paramtest(param_cls=p13n.MilenageRotationConstants,
val=bytearray(b'\x0a\x0b\x0c\x01\x02'),
expect_clean_val=b'\x0a\x0b\x0c\x01\x02',
expect_val='0a0b0c0102'),
Paramtest(param_cls=p13n.MilenageXoringConstants,
val='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
' bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
' cccccccccccccccccccccccccccccccc'
' 11111111111111111111111111111111'
' 22222222222222222222222222222222',
expect_clean_val=b'\xaa' * 16
+ b'\xbb' * 16
+ b'\xcc' * 16
+ b'\x11' * 16
+ b'\x22' * 16,
expect_val='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
'cccccccccccccccccccccccccccccccc'
'11111111111111111111111111111111'
'22222222222222222222222222222222'),
Paramtest(param_cls=p13n.MilenageXoringConstants,
val=b'\xaa' * 16
+ b'\xbb' * 16
+ b'\xcc' * 16
+ b'\x11' * 16
+ b'\x22' * 16,
expect_clean_val=b'\xaa' * 16
+ b'\xbb' * 16
+ b'\xcc' * 16
+ b'\x11' * 16
+ b'\x22' * 16,
expect_val='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
'cccccccccccccccccccccccccccccccc'
'11111111111111111111111111111111'
'22222222222222222222222222222222'),
Paramtest(param_cls=p13n.MncLen,
val='2',
expect_clean_val=2,
expect_val='2'),
Paramtest(param_cls=p13n.MncLen,
val=3,
expect_clean_val=3,
expect_val='3'),
]
for sdkey_cls in (
# thin out the number of tests, as a compromise between completeness and test runtime
p13n.SdKeyScp02Kvn20AesDek,
#p13n.SdKeyScp02Kvn20AesEnc,
#p13n.SdKeyScp02Kvn20AesMac,
#p13n.SdKeyScp02Kvn21AesDek,
p13n.SdKeyScp02Kvn21AesEnc,
#p13n.SdKeyScp02Kvn21AesMac,
#p13n.SdKeyScp02Kvn22AesDek,
#p13n.SdKeyScp02Kvn22AesEnc,
p13n.SdKeyScp02Kvn22AesMac,
#p13n.SdKeyScp02KvnffAesDek,
#p13n.SdKeyScp02KvnffAesEnc,
#p13n.SdKeyScp02KvnffAesMac,
p13n.SdKeyScp03Kvn30AesDek,
#p13n.SdKeyScp03Kvn30AesEnc,
#p13n.SdKeyScp03Kvn30AesMac,
#p13n.SdKeyScp03Kvn31AesDek,
p13n.SdKeyScp03Kvn31AesEnc,
#p13n.SdKeyScp03Kvn31AesMac,
#p13n.SdKeyScp03Kvn32AesDek,
#p13n.SdKeyScp03Kvn32AesEnc,
p13n.SdKeyScp03Kvn32AesMac,
#p13n.SdKeyScp80Kvn01AesDek,
#p13n.SdKeyScp80Kvn01AesEnc,
#p13n.SdKeyScp80Kvn01AesMac,
p13n.SdKeyScp80Kvn01DesDek,
#p13n.SdKeyScp80Kvn01DesEnc,
#p13n.SdKeyScp80Kvn01DesMac,
#p13n.SdKeyScp80Kvn02AesDek,
p13n.SdKeyScp80Kvn02AesEnc,
#p13n.SdKeyScp80Kvn02AesMac,
#p13n.SdKeyScp80Kvn02DesDek,
#p13n.SdKeyScp80Kvn02DesEnc,
p13n.SdKeyScp80Kvn02DesMac,
#p13n.SdKeyScp80Kvn03AesDek,
#p13n.SdKeyScp80Kvn03AesEnc,
#p13n.SdKeyScp80Kvn03AesMac,
p13n.SdKeyScp80Kvn03DesDek,
#p13n.SdKeyScp80Kvn03DesEnc,
#p13n.SdKeyScp80Kvn03DesMac,
#p13n.SdKeyScp81Kvn40AesDek,
p13n.SdKeyScp81Kvn40DesDek,
#p13n.SdKeyScp81Kvn40Tlspsk,
#p13n.SdKeyScp81Kvn41AesDek,
#p13n.SdKeyScp81Kvn41DesDek,
p13n.SdKeyScp81Kvn41Tlspsk,
#p13n.SdKeyScp81Kvn42AesDek,
#p13n.SdKeyScp81Kvn42DesDek,
#p13n.SdKeyScp81Kvn42Tlspsk,
):
for key_len in sdkey_cls.allow_len:
val = '0102030405060708091011121314151617181920212223242526272829303132'
expect_clean_val = (b'\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16'
b'\x17\x18\x19\x20\x21\x22\x23\x24\x25\x26\x27\x28\x29\x30\x31\x32')
expect_val = '0102030405060708091011121314151617181920212223242526272829303132'
val = val[:key_len*2]
expect_clean_val = expect_clean_val[:key_len]
expect_val = val
param_tests.append(Paramtest(param_cls=sdkey_cls, val=val, expect_clean_val=expect_clean_val, expect_val=expect_val))
# test bytes input
val = expect_clean_val
param_tests.append(Paramtest(param_cls=sdkey_cls, val=val, expect_clean_val=expect_clean_val, expect_val=expect_val))
# test bytearray input
val = bytearray(expect_clean_val)
param_tests.append(Paramtest(param_cls=sdkey_cls, val=val, expect_clean_val=expect_clean_val, expect_val=expect_val))
# test BytesIO input
val = io.BytesIO(expect_clean_val)
param_tests.append(Paramtest(param_cls=sdkey_cls, val=val, expect_clean_val=expect_clean_val, expect_val=expect_val))
if key_len == 16:
# test huge integer input.
# needs to start with nonzero.. stupid
val = 11020304050607080910111213141516
expect_clean_val = (b'\x11\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16')
expect_val = '11020304050607080910111213141516'
param_tests.append(Paramtest(param_cls=sdkey_cls, val=val, expect_clean_val=expect_clean_val, expect_val=expect_val))
outputs = []
for upp_fname in upp_fnames:
test_idx = -1
try:
der = resources.read_binary(smdpp_data.upp, upp_fname)
for t in param_tests:
test_idx += 1
logloc = f'{upp_fname} {t.param_cls.__name__}(val={valtypestr(t.val)})'
param = None
try:
param = t.param_cls()
param.input_value = t.val
param.validate()
except ValueError as e:
raise ValueError(f'{logloc}: {e}') from e
clean_val = param.value
logloc = f'{logloc} clean_val={valtypestr(clean_val)}'
if t.expect_clean_val is not None and t.expect_clean_val != clean_val:
raise ValueError(f'{logloc}: expected'
f' expect_clean_val={valtypestr(t.expect_clean_val)}')
# on my laptop, deepcopy is about 30% slower than decoding the DER from scratch:
# pes = copy.deepcopy(orig_pes)
pes = ProfileElementSequence.from_der(der)
try:
param.apply(pes)
except ValueError as e:
raise ValueError(f'{logloc} apply_val(clean_val): {e}') from e
changed_der = pes.to_der()
pes2 = ProfileElementSequence.from_der(changed_der)
read_back_val = t.param_cls.get_value_from_pes(pes2)
# compose log string to show the precise type of dict values
if isinstance(read_back_val, dict):
types = set()
for v in read_back_val.values():
types.add(f'{type(v).__name__}')
read_back_val_type = '{' + ', '.join(types) + '}'
else:
read_back_val_type = f'{type(read_back_val).__name__}'
logloc = (f'{logloc} read_back_val={valtypestr(read_back_val)}')
if isinstance(read_back_val, dict) and not t.param_cls.get_name() in read_back_val.keys():
raise ValueError(f'{logloc}: expected to find name {t.param_cls.get_name()!r} in read_back_val')
expect_val = t.expect_val
if not isinstance(expect_val, dict):
expect_val = { t.param_cls.get_name(): expect_val }
if read_back_val != expect_val:
raise ValueError(f'{logloc}: expected {expect_val=!r}:{type(t.expect_val).__name__}')
ok = logloc.replace(' clean_val', '\n\tclean_val'
).replace(' read_back_val', '\n\tread_back_val'
).replace('=', '=\t'
)
output = f'\nok: {ok}'
outputs.append(output)
print(output)
except Exception as e:
raise RuntimeError(f'Error while testing UPP {upp_fname} {test_idx=}: {e}') from e
output = '\n'.join(outputs) + '\n'
xo_name = 'test_configurable_parameters'
if update_expected_output:
with resources.path(xo, xo_name) as xo_path:
with open(xo_path, 'w', encoding='utf-8') as f:
f.write(output)
else:
xo_str = resources.read_text(xo, xo_name)
if xo_str != output:
at = 0
while at < len(output):
if output[at] == xo_str[at]:
at += 1
continue
break
raise RuntimeError(f'output differs from expected output at position {at}: "{output[at:at+20]}" != "{xo_str[at:at+20]}"')
if __name__ == "__main__":
if '-u' in sys.argv:
update_expected_output = True
sys.argv.remove('-u')
unittest.main()

View File

@@ -21,7 +21,7 @@ import copy
from osmocom.utils import h2b, b2h
from pySim.esim.saip import *
from pySim.esim.saip.personalization import *
from pySim.esim.saip import personalization
from pprint import pprint as pp
@@ -55,14 +55,56 @@ class SaipTest(unittest.TestCase):
def test_personalization(self):
"""Test some of the personalization operations."""
pes = copy.deepcopy(self.pes)
params = [Puk1('01234567'), Puk2(98765432), Pin1('1111'), Pin2(2222), Adm1('11111111'),
K(h2b('000102030405060708090a0b0c0d0e0f')), Opc(h2b('101112131415161718191a1b1c1d1e1f'))]
params = [personalization.Puk1('01234567'),
personalization.Puk2(98765432),
personalization.Pin1('1111'),
personalization.Pin2(2222),
personalization.Adm1('11111111'),
personalization.K(h2b('000102030405060708090a0b0c0d0e0f')),
personalization.Opc(h2b('101112131415161718191a1b1c1d1e1f'))]
for p in params:
p.validate()
p.apply(pes)
# TODO: we don't actually test the results here, but we just verify there is no exception
pes.to_der()
def test_personalization2(self):
"""Test some of the personalization operations."""
cls = personalization.SdKeyScp80Kvn01DesEnc
pes = ProfileElementSequence.from_der(self.per_input)
prev_val = tuple(cls.get_values_from_pes(pes))
print(f'{prev_val=}')
self.assertTrue(prev_val)
set_val = '42342342342342342342342342342342'
param = cls(set_val)
param.validate()
param.apply(pes)
get_val1 = tuple(cls.get_values_from_pes(pes))
print(f'{get_val1=} {set_val=}')
self.assertEqual(get_val1, ({cls.name: set_val},))
get_val1b = tuple(cls.get_values_from_pes(pes))
print(f'{get_val1b=} {set_val=}')
self.assertEqual(get_val1b, ({cls.name: set_val},))
der = pes.to_der()
get_val1c = tuple(cls.get_values_from_pes(pes))
print(f'{get_val1c=} {set_val=}')
self.assertEqual(get_val1c, ({cls.name: set_val},))
# assertTrue to not dump the entire der.
# Expecting the modified DER to be different. If this assertion fails, then no change has happened in the output
# DER and the ConfigurableParameter subclass is buggy.
self.assertTrue(der != self.per_input)
pes2 = ProfileElementSequence.from_der(der)
get_val2 = tuple(cls.get_values_from_pes(pes2))
print(f'{get_val2=} {set_val=}')
self.assertEqual(get_val2, ({cls.name: set_val},))
def test_constructor_encode(self):
"""Test that DER-encoding of PE created by "empty" constructor works without raising exception."""
for cls in [ProfileElementMF, ProfileElementPuk, ProfileElementPin, ProfileElementTelecom,
@@ -90,5 +132,34 @@ class OidTest(unittest.TestCase):
self.assertTrue(oid.OID('1.0.1') > oid.OID('1.0'))
self.assertTrue(oid.OID('1.0.2') > oid.OID('1.0.1'))
class NonMatchTest(unittest.TestCase):
def test_nonmatch(self):
# non-matches before, in between and after matches
match_list = [Match(a=10, b=10, size=5), Match(a=20, b=20, size=4)]
nm_list = NonMatch.from_matchlist(match_list, 26)
self.assertEqual(nm_list, [NonMatch(a=0, b=0, size=10), NonMatch(a=15, b=15, size=5),
NonMatch(a=24, b=24, size=2)])
def test_nonmatch_beg(self):
# single match at beginning
match_list = [Match(a=0, b=0, size=5)]
nm_list = NonMatch.from_matchlist(match_list, 20)
self.assertEqual(nm_list, [NonMatch(a=5, b=5, size=15)])
def test_nonmatch_end(self):
# single match at end
match_list = [Match(a=19, b=19, size=5)]
nm_list = NonMatch.from_matchlist(match_list, 24)
self.assertEqual(nm_list, [NonMatch(a=0, b=0, size=19)])
def test_nonmatch_none(self):
# no match at all
match_list = []
nm_list = NonMatch.from_matchlist(match_list, 24)
self.assertEqual(nm_list, [NonMatch(a=0, b=0, size=24)])
if __name__ == "__main__":
unittest.main()

View File

@@ -96,7 +96,7 @@ class LinFixed_Test(unittest.TestCase):
inst = c()
encoded, rec_num, decoded = self._parse_t(t)
logging.debug("Testing encode of %s", name)
re_enc = inst.encode_record_hex(decoded, rec_num)
re_enc = inst.encode_record_hex(decoded, rec_num, len(encoded)//2)
self.assertEqual(encoded.upper(), re_enc.upper())
def test_de_encode_record(self):
@@ -122,7 +122,7 @@ class LinFixed_Test(unittest.TestCase):
self.assertEqual(decoded, re_dec)
# re-encode the decoded data
logging.debug("Testing re-encode of %s", name)
re_enc = inst.encode_record_hex(re_dec, rec_num)
re_enc = inst.encode_record_hex(re_dec, rec_num, len(encoded)//2)
self.assertEqual(encoded.upper(), re_enc.upper())
if hasattr(c, '_test_no_pad') and c._test_no_pad:
continue
@@ -176,12 +176,11 @@ class TransRecEF_Test(unittest.TestCase):
def test_de_encode_record(self):
"""Test the decoder and encoder for a transparent record-oriented EF. Performs first a decoder
test, and then re-encodes the decoded data, comparing the re-encoded data with the
initial input data.
"""Test the decoder and encoder for a transparent record-oriented EF at the whole-file
level. Performs first a decode test, then re-encodes and compares with the input.
Requires the given TransRecEF subclass to have a '_test_de_encode' attribute,
containing a list of tuples. Each tuple has to be a 2-tuple (hexstring, decoded_dict).
containing a list of 2-tuples (hexstring, decoded_list).
"""
for c in self.classes:
name = get_qualified_name(c)
@@ -192,14 +191,12 @@ class TransRecEF_Test(unittest.TestCase):
encoded = t[0]
decoded = t[1]
logging.debug("Testing decode of %s", name)
re_dec = inst.decode_record_hex(encoded)
re_dec = inst.decode_hex(encoded)
self.assertEqual(decoded, re_dec)
# re-encode the decoded data
logging.debug("Testing re-encode of %s", name)
re_enc = inst.encode_record_hex(re_dec)
re_enc = inst.encode_hex(re_dec, len(encoded)//2)
self.assertEqual(encoded.upper(), re_enc.upper())
# there's no point in testing padded input, as TransRecEF have a fixed record
# size and we cannot ever receive more input data than that size.
class TransparentEF_Test(unittest.TestCase):
@@ -256,8 +253,8 @@ class TransparentEF_Test(unittest.TestCase):
encoded = t[0]
decoded = t[1]
logging.debug("Testing encode of %s", name)
re_dec = inst.decode_hex(encoded)
self.assertEqual(decoded, re_dec)
re_enc = inst.encode_hex(decoded, len(encoded)//2)
self.assertEqual(encoded, re_enc)
def test_de_encode_file(self):
"""Test the decoder and encoder for a transparent EF. Performs first a decoder
@@ -280,7 +277,7 @@ class TransparentEF_Test(unittest.TestCase):
self.assertEqual(decoded, re_dec)
logging.debug("Testing re-encode of %s", name)
re_dec = inst.decode_hex(encoded)
re_enc = inst.encode_hex(re_dec)
re_enc = inst.encode_hex(re_dec, len(encoded)//2)
self.assertEqual(encoded.upper(), re_enc.upper())
if hasattr(c, '_test_no_pad') and c._test_no_pad:
continue

View File

@@ -0,0 +1,144 @@
#!/usr/bin/env python3
# (C) 2026 by sysmocom - s.f.m.c. GmbH <info@sysmocom.de>
#
# 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 <http://www.gnu.org/licenses/>.
"""Verify that every CardProfile / CardApplication subclass with EF/DF content,
and every standalone CardDF subclass (one not reachable as a child of any profile
or application), is either listed in docs/pysim_fs_sphinx.py::SECTIONS or
explicitly EXCLUDED."""
import unittest
import importlib
import inspect
import pkgutil
import sys
import os
# Make docs/pysim_fs_sphinx.py importable without a full Sphinx build.
_DOCS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..', 'docs')
sys.path.insert(0, os.path.abspath(_DOCS_DIR))
import pySim # noqa: E402
from pySim.filesystem import CardApplication, CardDF, CardMF, CardADF # noqa: E402
from pySim.profile import CardProfile # noqa: E402
from pysim_fs_sphinx import EXCLUDED, SECTIONS # noqa: E402
class TestFsCoverage(unittest.TestCase):
"""Ensure SECTIONS + EXCLUDED together account for all classes with content."""
# Base CardDF types that are not concrete filesystem objects on their own.
_DF_BASE_TYPES = frozenset([CardDF, CardMF, CardADF])
@staticmethod
def _collect_reachable_df_types(obj) -> set:
"""Return the set of all CardDF *types* reachable as children of *obj*."""
result = set()
if isinstance(obj, CardProfile):
children = obj.files_in_mf
elif isinstance(obj, CardApplication):
result.add(type(obj.adf))
children = list(obj.adf.children.values())
elif isinstance(obj, CardDF):
children = list(obj.children.values())
else:
return result
queue = list(children)
while queue:
child = queue.pop()
if isinstance(child, CardDF):
result.add(type(child))
queue.extend(child.children.values())
return result
@staticmethod
def _has_content(obj) -> bool:
"""Return True if *obj* owns any EFs/DFs."""
if isinstance(obj, CardProfile):
return bool(obj.files_in_mf)
if isinstance(obj, CardApplication):
return bool(obj.adf.children)
return False
def test_all_profiles_and_apps_covered(self):
# build a set of (module, class-name) pairs that are already accounted for
covered = {(mod, cls) for (_, mod, cls) in SECTIONS}
accounted_for = covered | EXCLUDED
uncovered = []
reachable_df_types = set()
loaded_modules = {}
for modinfo in pkgutil.walk_packages(pySim.__path__, prefix='pySim.'):
modname = modinfo.name
try:
module = importlib.import_module(modname)
except Exception: # skip inport errors, if any
continue
loaded_modules[modname] = module
for name, cls in inspect.getmembers(module, inspect.isclass):
# skip classes that are merely imported by this module
if cls.__module__ != modname:
continue
# examine only subclasses of CardProfile and CardApplication
if not issubclass(cls, (CardProfile, CardApplication)):
continue
# skip the abstract base classes themselves
if cls in (CardProfile, CardApplication):
continue
# classes that require constructor arguments cannot be probed
try:
obj = cls()
except Exception:
continue
# collect all CardDF types reachable from this profile/application
# (used below to identify standalone DFs)
reachable_df_types |= self._collect_reachable_df_types(obj)
if self._has_content(obj) and (modname, name) not in accounted_for:
uncovered.append((modname, name))
# check standalone CardDFs (such as DF.EIRENE or DF.SYSTEM)
for modname, module in loaded_modules.items():
for name, cls in inspect.getmembers(module, inspect.isclass):
if cls.__module__ != modname:
continue
if not issubclass(cls, CardDF):
continue
if cls in self._DF_BASE_TYPES:
continue
if cls in reachable_df_types:
continue
try:
obj = cls()
except Exception:
continue
if obj.children and (modname, name) not in accounted_for:
uncovered.append((modname, name))
if uncovered:
lines = [
'The following classes have EFs/DFs, but not listed in SECTIONS or EXCLUDED:',
*(f' {modname}.{name}' for modname, name in sorted(uncovered)),
'Please modify docs/pysim_fs_sphinx.py accordingly',
]
self.fail('\n'.join(lines))
if __name__ == '__main__':
unittest.main()

View File

@@ -295,7 +295,7 @@ class Install_param_Test(unittest.TestCase):
load_parameters = gen_install_parameters(256, 256, '010001001505000000000000000000000000')
self.assertEqual(load_parameters, 'c900ef1cc8020100c7020100ca12010001001505000000000000000000000000')
load_parameters = gen_install_parameters(None, None, '')
load_parameters = gen_install_parameters()
self.assertEqual(load_parameters, 'c900')
if __name__ == "__main__":

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env python3
# (C) 2025 by Sysmocom s.f.m.c. GmbH
# (C) 2025 by sysmocom - s.f.m.c. GmbH
# All Rights Reserved
#
# Author: Philipp Maier <pmaier@sysmocom.de>
@@ -25,7 +25,7 @@ import io
import sys
from inspect import currentframe, getframeinfo
log = PySimLogger.get("TEST")
log = PySimLogger.get(__name__)
TEST_MSG_DEBUG = "this is a debug message"
TEST_MSG_INFO = "this is an info message"
@@ -82,15 +82,15 @@ class PySimLogger_Test(unittest.TestCase):
PySimLogger.setup(self._test_print_callback)
PySimLogger.set_verbose(True)
frame = currentframe()
expected_message = __name__ + "." + str(getframeinfo(frame).lineno + 1) + " -- TEST - DEBUG: " + TEST_MSG_DEBUG
expected_message = __name__ + "." + str(getframeinfo(frame).lineno + 1) + " -- DEBUG: " + TEST_MSG_DEBUG
log.debug(TEST_MSG_DEBUG)
expected_message = __name__ + "." + str(getframeinfo(frame).lineno + 1) + " -- TEST - INFO: " + TEST_MSG_INFO
expected_message = __name__ + "." + str(getframeinfo(frame).lineno + 1) + " -- INFO: " + TEST_MSG_INFO
log.info(TEST_MSG_INFO)
expected_message = __name__ + "." + str(getframeinfo(frame).lineno + 1) + " -- TEST - WARNING: " + TEST_MSG_WARNING
expected_message = __name__ + "." + str(getframeinfo(frame).lineno + 1) + " -- WARNING: " + TEST_MSG_WARNING
log.warning(TEST_MSG_WARNING)
expected_message = __name__ + "." + str(getframeinfo(frame).lineno + 1) + " -- TEST - ERROR: " + TEST_MSG_ERROR
expected_message = __name__ + "." + str(getframeinfo(frame).lineno + 1) + " -- ERROR: " + TEST_MSG_ERROR
log.error(TEST_MSG_ERROR)
expected_message = __name__ + "." + str(getframeinfo(frame).lineno + 1) + " -- TEST - CRITICAL: " + TEST_MSG_CRITICAL
expected_message = __name__ + "." + str(getframeinfo(frame).lineno + 1) + " -- CRITICAL: " + TEST_MSG_CRITICAL
log.critical(TEST_MSG_CRITICAL)
def test_04_level(self):

216
tests/unittests/test_param_src.py Executable file
View File

@@ -0,0 +1,216 @@
#!/usr/bin/env python3
# (C) 2025 by sysmocom - s.f.m.c. GmbH <info@sysmocom.de>
#
# Author: Neels Hofmeyr
#
# 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 <http://www.gnu.org/licenses/>.
import sys
import math
from importlib import resources
import unittest
from pySim.esim.saip import param_source
import xo
update_expected_output = False
class D:
mandatory = set()
optional = set()
def __init__(self, **kwargs):
if (set(kwargs.keys()) - set(self.optional)) != set(self.mandatory):
raise RuntimeError(f'{self.__class__.__name__}.__init__():'
f' {set(kwargs.keys())=!r} - {self.optional=!r} != {self.mandatory=!r}')
for k, v in kwargs.items():
setattr(self, k, v)
for k in self.optional:
if not hasattr(self, k):
setattr(self, k, None)
decimals = '0123456789'
hexadecimals = '0123456789abcdefABCDEF'
class FakeRandom:
vals = b'\xab\xcfm\xf0\x98J_\xcf\x96\x87fp5l\xe7f\xd1\xd6\x97\xc1\xf9]\x8c\x86+\xdb\t^ke\xc1r'
i = 0
@classmethod
def next(cls):
cls.i = (cls.i + 1) % len(cls.vals)
return cls.vals[cls.i]
@staticmethod
def randint(a, b):
d = b - a
n_bytes = math.ceil(math.log(d, 2))
r = int.from_bytes( bytes(FakeRandom.next() for i in range(n_bytes)) )
return a + (r % (b - a))
@staticmethod
def randbytes(n):
return bytes(FakeRandom.next() for i in range(n))
class ParamSourceTest(unittest.TestCase):
def test_param_source(self):
class ParamSourceTest(D):
mandatory = (
'param_source',
'n',
'expect',
)
optional = (
'expect_arg',
'csv_rows',
)
def expect_const(t, vals):
return tuple(t.expect_arg) == tuple(vals)
def expect_random(t, vals):
chars = t.expect_arg.get('digits')
repetitions = (t.n - len(set(vals)))
if repetitions:
raise RuntimeError(f'expect_random: there are {repetitions} repetitions in the returned values: {vals}')
for val_i in range(len(vals)):
v = vals[val_i]
val_minlen = t.expect_arg.get('val_minlen')
val_maxlen = t.expect_arg.get('val_maxlen')
if len(v) < val_minlen or len(v) > val_maxlen:
raise RuntimeError(f'expect_random: invalid length {len(v)} for value [{val_i}]: {v!r}, expecting'
f' {val_minlen}..{val_maxlen}')
if chars is not None and not all(c in chars for c in v):
raise RuntimeError(f'expect_random: invalid char in value [{val_i}]: {v!r}')
return True
param_source_tests = [
ParamSourceTest(param_source=param_source.ConstantSource.from_str('123'),
n=3,
expect=expect_const,
expect_arg=('123', '123', '123')
),
ParamSourceTest(param_source=param_source.RandomDigitSource.from_str('12345'),
n=3,
expect=expect_random,
expect_arg={'digits': decimals,
'val_minlen': 5,
'val_maxlen': 5,
},
),
ParamSourceTest(param_source=param_source.RandomDigitSource.from_str('1..999'),
n=10,
expect=expect_random,
expect_arg={'digits': decimals,
'val_minlen': 1,
'val_maxlen': 3,
},
),
ParamSourceTest(param_source=param_source.RandomDigitSource.from_str('001..999'),
n=10,
expect=expect_random,
expect_arg={'digits': decimals,
'val_minlen': 3,
'val_maxlen': 3,
},
),
ParamSourceTest(param_source=param_source.RandomHexDigitSource.from_str('12345678'),
n=3,
expect=expect_random,
expect_arg={'digits': hexadecimals,
'val_minlen': 8,
'val_maxlen': 8,
},
),
ParamSourceTest(param_source=param_source.RandomHexDigitSource.from_str('0*8'),
n=3,
expect=expect_random,
expect_arg={'digits': hexadecimals,
'val_minlen': 8,
'val_maxlen': 8,
},
),
ParamSourceTest(param_source=param_source.RandomHexDigitSource.from_str('00*4'),
n=3,
expect=expect_random,
expect_arg={'digits': hexadecimals,
'val_minlen': 8,
'val_maxlen': 8,
},
),
ParamSourceTest(param_source=param_source.IncDigitSource.from_str('10001'),
n=3,
expect=expect_const,
expect_arg=('10001', '10002', '10003')
),
ParamSourceTest(param_source=param_source.CsvSource('column_name'),
n=3,
expect=expect_const,
expect_arg=('first val', 'second val', 'third val'),
csv_rows=(
{'column_name': 'first val',},
{'column_name': 'second val',},
{'column_name': 'third val',},
)
),
]
outputs = []
for t in param_source_tests:
try:
if hasattr(t.param_source, 'random_impl'):
t.param_source.random_impl = FakeRandom
vals = []
for i in range(t.n):
csv_row = None
if t.csv_rows is not None:
csv_row = t.csv_rows[i]
vals.append( t.param_source.get_next(csv_row=csv_row) )
if not t.expect(t, vals):
raise RuntimeError(f'invalid values returned: returned {vals}')
output = f'ok: {t.param_source.__class__.__name__} {vals=!r}'
outputs.append(output)
print(output)
except RuntimeError as e:
raise RuntimeError(f'{t.param_source.__class__.__name__} {t.n=} {t.expect.__name__}({t.expect_arg!r}): {e}') from e
output = '\n'.join(outputs) + '\n'
xo_name = 'test_param_src'
if update_expected_output:
with resources.path(xo, xo_name) as xo_path:
with open(xo_path, 'w', encoding='utf-8') as f:
f.write(output)
else:
xo_str = resources.read_text(xo, xo_name)
if xo_str != output:
at = 0
while at < len(output):
if output[at] == xo_str[at]:
at += 1
continue
break
raise RuntimeError(f'output differs from expected output at position {at}: {xo_str[at:at+128]!r}')
if __name__ == "__main__":
if '-u' in sys.argv:
update_expected_output = True
sys.argv.remove('-u')
unittest.main()

View File

@@ -79,6 +79,30 @@ class DecTestCase(unittest.TestCase):
def testDecMNCfromPLMN_unused_str(self):
self.assertEqual(utils.dec_mnc_from_plmn_str("00f0ff"), "")
def testEncImsi(self):
#Type IMSI, odd number of identity digits
self.assertEqual(utils.enc_imsi("228062800000208"), "082982608200002080")
self.assertEqual(utils.enc_imsi("001010000123456"), "080910100000214365")
self.assertEqual(utils.enc_imsi("0010100001234"), "0709101000002143ff")
#Type IMSI, even number of identity digits
self.assertEqual(utils.enc_imsi("22806280000028"), "0821826082000020f8")
self.assertEqual(utils.enc_imsi("00101000012345"), "0801101000002143f5")
self.assertEqual(utils.enc_imsi("001010000123"), "07011010000021f3ff")
def testDecImsi(self):
#Type IMSI, odd number of identity digits
self.assertEqual(utils.dec_imsi("082982608200002080"), "228062800000208")
self.assertEqual(utils.dec_imsi("080910100000214365"), "001010000123456")
self.assertEqual(utils.dec_imsi("0709101000002143ff"), "0010100001234")
self.assertEqual(utils.dec_imsi("0709101000002143"), "0010100001234")
#Type IMSI, even number of identity digits
self.assertEqual(utils.dec_imsi("0821826082000020f8"), "22806280000028")
self.assertEqual(utils.dec_imsi("0801101000002143f5"), "00101000012345")
self.assertEqual(utils.dec_imsi("07011010000021f3ff"), "001010000123")
self.assertEqual(utils.dec_imsi("07011010000021f3"), "001010000123")
def test_enc_plmn(self):
with self.subTest("2-digit MCC"):
self.assertEqual(utils.enc_plmn("001", "01F"), "00F110")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
ok: ConstantSource vals=['123', '123', '123']
ok: RandomDigitSource vals=['13987', '49298', '55670']
ok: RandomDigitSource vals=['650', '580', '49', '885', '497', '195', '320', '137', '245', '663']
ok: RandomDigitSource vals=['638', '025', '232', '779', '826', '972', '650', '580', '049', '885']
ok: RandomHexDigitSource vals=['6b65c172', 'abcf6df0', '984a5fcf']
ok: RandomHexDigitSource vals=['96876670', '356ce766', 'd1d697c1']
ok: RandomHexDigitSource vals=['f95d8c86', '2bdb095e', '6b65c172']
ok: IncDigitSource vals=['10001', '10002', '10003']
ok: CsvSource vals=['first val', 'second val', 'third val']