Compare commits

...

141 Commits

Author SHA1 Message Date
Eric Wild 3c437d41e0 sysmocom_sjs1: CardModel for the sysmoUSIM-SJS1
SJS1 model lifted off the the legacy
SysmoUSIMSJS1.program(), ADM is ADM1 with CHV 0x0a,
already used by the generic UiccCardBase default.

Change-Id: I32b011c68de5f990e709718b313b6f28a31ca78b
2026-09-23 18:01:16 +02:00
Eric Wild 2b7abdcf96 pySim.log: apply cmd2 colors through _style()
The color scheme takes either
- a raw escape sequence like "\033[33m" (pySim-read,
pySim-prog, contrib/csv-to-pgsql.py)
- a cmd2 color object (pySim-shell)

Color of cmd2 >= 3.0.0 is a StrEnum, which led to
pySim-shell warnings like

  yellowWARNING: EF.DIR seems to be empty!

with a stray reset at the end.

Fixed by exclusing Enum from the raw sequence branch.

Change-Id: I8bae9fc3c54f1dab481d743d3b68e32e9d09267a
2026-09-23 18:01:16 +02:00
Eric Wild aeba4004de contrib: add scp81 OTA trigger builder
Change-Id: Ic646626319cd7a26195d0ffff55900032abcddeb
2026-09-23 18:01:16 +02:00
Eric Wild 632d585cfc bip: report a location in PROVIDE LOCAL INFORMATION
TS 102 223 6.8.7: TERMINAL RESPONSE shall contain the data object the
command qualifier (6.6.15) asked for.
The card always asks for location information, so at least answer 00,
with the GERAN form of TS 131.111 8.19.1, PLMN 262-01.

Do not answer "terminal currently unable to process - no service"...

Change-Id: I53547da2ea9f0a4ea7a4dd032985a546f2d4277f
2026-09-23 18:01:16 +02:00
Eric Wild 7e8f711ec2 bip/smpp2sim: TERMINAL PROFILE that matches what we do
pySim-smpp2sim sends "ff" * 32, that byte list tells every card the
terminal has a display, a keypad, a second card slot, a radio it can
query for location and NMR, five BIP bearers and six transport modes and
a toaster and a dog according to TS 102 223 5.2

We only have the twelfth byte and one bit of the seventeenth and no dog.

A card issues annoying weird things like PROVIDE LOCAL INFORMATION or
UDP only because the profile said so, so stop pretending we know what any
of that is.

Annex T table T.1 lists what a Connected Entity (a CAT client that is not
the modem) may announce. Announce that and the SMS-PP download and
SEND SHORT MESSAGE bits the OTA path needs, only the bearer is obviously
made up, it's the host TCP stack and 5.2 closest match is GPRS.

We can still extend and change all of this, but for now something that
works and constrains what the card asks for and is therefore actually
reproducible is important.

Change-Id: I91a60760fc3ad816b7385da8b26ec7330b462abd
2026-09-23 18:01:16 +02:00
Eric Wild d671cee649 bip: refuse OPEN CHANNEL with a TERMINAL RESPONSE
handle_OpenChannel() raises where TS 102 223 demands a TERMINAL RESPONSE.
Raising takes the whole proactive session down with it.

All four are a BIP errors and differ only in the cause byte of 8.12.11.

Change-Id: If8f01e6acb9cb3f7af952a0760fc093db81be53a
2026-09-23 18:01:16 +02:00
Eric Wild eb8e40948b osmo-smdpp: case insensitive comp of ES9+ SM-DP+ address
Do what the comment above the line already says..

Change-Id: Ie0738e810572ea3312c885ec8aaa2da4b8b12cd7
2026-09-23 18:01:16 +02:00
Eric Wild 0de8274e99 cat: properly decode IMEI/IMEISV IE
Currently BcdAdapter(GreedyBytes), i.e. a plain swapped nibble digit
string, which is wrong, the IMEI IE is not a bcd string.
See TS 124.008 10.5.1.4 figure 10.5.4 + table 10.5.4

It currently
 - decodes framing nibble as digit
 - encodes a 15 digit IMEI as 7 bytes, wrong len, dropped last digit

Fix this, while at it add the 8.74 IMEISV as well + tests for the framing
because the current round trip missed all of this.

Change-Id: Iad7aea77513d413c338edc18ef1bcc37488ae849
2026-09-23 18:01:16 +02:00
Eric Wild df8de1a69a cat: fix TS 102 223 8.61 Access technology coding
Spelling mistakes and new values missing.

Change-Id: I74f361cd5b818079636f399cb8c1b1c609b9e1f2
2026-09-23 18:01:16 +02:00
Eric Wild 6b40fe8546 cat: fix Supported Radio Access Technologies state flag
TS 102 223 8.105 codes each supported technology in two bytes:
- the technology from 8.61
- state byte, b1 0 disabled/1 enabled
- b2-b8 RFU

Currently declared as FlagsEnum(Int8ub, enabled=0). kw value is used as
bitmask, so currently:

build {'enabled': True}      -> b4 02 08 00     disabled
build {'enabled': False}     -> b4 02 08 00     disabled
but parsed 08 00 / 08 01 / 08 ff  -> all {'enabled': True}

Use the bit the spec names + tests because well this is so easy that we
don't need tests is apparently not the right approach here..

Change-Id: I4dd16182eb06c99265aabab5d889967b890f6bf2
2026-09-23 18:01:16 +02:00
Eric Wild 456c7873eb bip: answer proactive commands the relay does not implement
pySim-smpp2sim currently claims to support every feature flag that exists,
but this immediately dies here with a SJA5:

 NotImplementedError: No handler method for ProvideLocalInformation(...)

Answer anything unhandled with "performed_successfully" and log. Never
"command_beyond_terminal_capability", tho, answering that for PROVIDE LOCAL
INFORMATION refuses to open the session at all.

Change-Id: I1e55d6f88c872a04b89a3946dd840d30b329621e
2026-09-23 18:01:16 +02:00
Eric Wild 1b8c6b48ea cat: add sms_pp_download_envelope()
Put a helper for wrapping an SMS-DELIVER TPDU in the ENVELOPE
of TS 102 223 section 7.5.1 the assembly next to SMSPPDownload,
where the IEs already live, so it can be reused by other tooling,
for example for triggering scp81 sessions.

Change-Id: Id23227eac53d697f4f13a84e087c32bf1d60f474
2026-09-23 18:01:16 +02:00
Eric Wild fd83fbdb5f sms: don't mutate digits in AddressField.to_bytes()
to_bytes() appended the BCD filler nibble, so the next call saw one
digit more and derived a wrong length. This affects any code that encodes
an address twice, even for printing/logging.

Man, I really miss my beloved c++ const function decoration...

Fix: keep the filler in a local.
Change-Id: I81691c5a1fc5072d6d20c52d22da1eb2e180d04a
2026-09-23 18:01:16 +02:00
Eric Wild ff3f275c84 tools: print tracebacks to the same stream as delimiters
Error output is currently a garbled mess, delimiters are printed with
poutput() or print(), traceback with traceback.print_exc() which goes
to stderr.

Reading stdout alone omits the report but not -->8-- lines.
Reading both streams makes the traceback appear where the two buffers
just happen to interleave, stderr is usually unbuffered but stdout is
not, so I get the error trace, APDU trace, then empty -->8-- lines.

Fix this by aligning the printing funcs.

Change-Id: I2ed37dfe241d014e4a26894cddbc9b21be1fd0c2
2026-09-23 18:01:16 +02:00
Eric Wild a0e14a16f2 transport: trace APDUs when they are sent
Right now APDUs that never return/fail are omitted from the trace output.
Move the print, so it actually tells me what failed..

Change-Id: I828ea36b06da36b6bb0aa073d235d4f49cbb3161
2026-09-23 18:01:16 +02:00
Eric Wild 37719a0fcf pySim-shell: let --apdu-trace cover the session
pySim has two APDU tracers: the StdoutApduTracer that --apdu-trace
installs in the transport for every program using pySim.transport, and
pySim-shell Cmd2ApduTracer for "set apdu_trace true", which prints with
cmd2. The command line option exists because the setting
applies too late to see the pySim startup APDUs.

The guard in init_reader() exists but is useless:
    if opts.apdu_trace and not 'apdu_tracer' in kwargs:
no caller supplies a tracer that way, every tool calls init_reader()
without one?.

The current help string does not say "trace or maybe not lol" so set
the setting from the transport rather than hardcoding False, so the
option implies "set apdu_trace true", and it finally works as advertised.

Change-Id: I83469e15f4cdd67418cd11df3869f2536105dbc8
2026-09-23 18:01:16 +02:00
Eric Wild 26a3fc09dc GP: only send a GET STATUS tag list to cards that support it
get_status() has appended a hardcoded '5c054f9f70c5cc' to the command data
field. Of the data objects in GP CS v2.3.1 Table 11-35 only the AID
search tag 4F is mandatory, the tag list is optional and not supported
in v2.1.1, where Section 9.4.2.3 defines the data field as the search
qualifier. Cards implementing that revision can reject anything else
with 6A80 as per v2.1.1 Table 9-26.

A sysmocom SJA5 does that. Its data field must be one 4F
TLV, the value is free, but nothing may precede or follow it.
So every subset returned nothing at all...

There is no need to guess: v2.1.1/v2.3.1 Section 7.4.1.3 Card Recognition
Data is "shall be present" and contains the GP version on selected SD.
Query it once, and send the tag list to cards that announce v2.2 or later.
SJA5 reports 2.1.1, sysmoEUICC reports 2.2.

Two more problems with the old list:
- A tag list is an inclusion list, old list omits tag 84, so it
suppressed the Executable Module AIDs
- It asks for tag C5 for Executable Load Files, which "may" be answered
with an error status.

Fix this by constricting or omitting the tag list depending on reported
GP version.

Change-Id: I74cd2bd47617d616bede6453397f544cde5abcb7
2026-09-23 18:01:16 +02:00
Eric Wild 515925228d transport: stop the T=0 layer from breaking GP 6310
TS 102 221 section 7.3.1.1.4 clause 4b lets the card answer a case #4
command TPDU with a 62xx/63xx warning, upon which the terminal sends a
dummy GET RESPONSE to obtain the 61xx that announces the response length.
__send_apdu_T0() applies that unconditionally, to the status word that
terminates a GET RESPONSE...

That is not "redundant", as per GPC v2.3.1 section 11.4.3.2 table 11-38
GP GET STATUS (80 F2) answers 6310 "more data available", meaning reissue
the command as get next occurrence(s) with P2 bit 1 set (11.4.2.2 table
11-34), so for example a paginated registry
listing exchange looks like this:

  84f22000024f00  =>            61e4    first match, e4 bytes waiting
  84c00000e4      => <page 1>   6310    more matches pending
  84c0000000      =>            6982    <- unsolicited, card rejects it

The card rejects the unsolicited GET RESPONSE with 6982, our SCP02
session breaks, and the next command fail with 6985. The 6310 never
reaches ADF_SD.get_status() either, so the pagination loop exits after
page 1 and prints a sliently truncated listing. Observed with a SJA5.

Fix by turning clause 4b würgaround into what it should be: a one shot
reaction to the SW returned for the command TPDU.

While at it, stop ADF_SD.get_status() from silently returning a truncated
registry: it treated every status word other than 6310 as
"nothing more to report". 6A88 is now the explicit empty result and anything
else raises, so a partial listing can no longer silently pass.

Adds unit tests for all of that so we dont break basic T0 things.

Change-Id: I10f8afa8dd5623a49a6a0e7132607b3a1fad2d8c
2026-09-23 18:01:16 +02:00
Eric Wild da94468c5f osmo-smdpp: bind TLS endpoint to -H interface
klein ignores host/port when endpoint_description is set, so -H has
no effect on the TLS listener, it binds every interface.
Append interface= to the string so -H means the same thing with and
without TLS.

Change-Id: I737c2e7cfa1ca7b825bd36a4a489760918031b8b
2026-09-23 18:01:16 +02:00
Eric Wild 1c9072541b osmo-smdpp: add --smdp-address to set ES9+ SM-DP+ address
The ES9+ SM-DP+ address is used for the smdpAddress check and
for serverSigned1.serverAddress, both default to HOSTNAME.

Add --smdp-address (default: HOSTNAME) so the advertised address can carry a
port and still match what the LPA connects to when the TLS endpoint is bound
to other ports than 443. TLS certificate identity is unaffected, this is
only the ES9+ address.

SGP.22 defines both smdpAddress and serverSigned1.serverAddress as an FQDN.
Taken strictly that leaves no way to run an SM-DP+ on a port other than
443, so an address with a port is arguably outside the spec but needed
for testing.

Change-Id: I2fa822c7d5e0d5b68a6704a1779f9923505f9008
2026-09-23 18:01:16 +02:00
Eric Wild e4e491ce58 osmo-smdpp: fix Twisted ALPN issues with pyOpenSSL
pyOpenSSL >= 25.0.0 makes a Context immutable once it has been used and
raises. Downgrading pyOpenSSL is not a fix either: < 25 does not import
against recent cryptography.

This server only speaks HTTP/1.1 anway so ALPN negotiation is unused
and this can be hotpatched for affected versions.

Change-Id: I5d53216f24a20625d12f0757015c19fe341303b9
2026-09-23 18:01:16 +02:00
Eric Wild e70d9ec0c9 contrib: SCP81 TLS-PSK RAS for RAM/RFM over HTTPS
GP Amendment B "Remote Application Management over HTTP" v1.2 and
ETSI TS 102 226 RFM/RAM server.

APDU command/response bodies use the Expanded Remote Application data
format.

--mode {ram,rfm} selects what we want to do, make sure to
target the right thing in the right mode, SD for RAM, AID for RFM.

This is hand-rolled on purpose:
Klein/Twisted do not help us here, have annoying headers and settings,
and don't work well at all if you want to import the file for tests and
so on. I have wasted quite some time trying to make this prettier by
re-using existing http handler parts because I thought well we import
all kinds of packages already it can't be that hard, unfortunately it is.

Change-Id: I4f243aeca65d0f33ff34e6932e3f4e156446af8b
2026-09-23 18:01:16 +02:00
Eric Wild 6076e4e6ff ota: indefinite length en/decoding support
Adds TS 102 226 tables 5.2a/5.10a indefinite length coding,
recommended by TS 102 226 5.2.1 for RAM/RFM over HTTPS.

Noteworthy notable things to note:
- encode_expanded_cmd()
  indefinite version -> inner C-APDU TLVs are still definite
- decode_expanded_resp()s returned container does not care, but
  Indef has no 'number of executed commands' TLV -> number_of_commands
  is the R-APDU count.

Tests are being fed with some known-good values from my sja5 sessions.

Change-Id: I4e023112e98729489ed443eec3ed5ab45c773b17
2026-09-23 18:01:16 +02:00
Eric Wild 6313b83e0e smpp2sim: make the SCP81 BIP relay work
The BIP relay ( the "handset" side for SCP81) never worked: the
connect callback in handle_OpenChannel was "never called" as the fixme
says, everything else was missing.

Fixme cause: card APDU I/O is driven synchronously, proactive command loop
lives in a blocking while loop (pySim.transport.LinkBase.send_apdu_checksw)
that runs on the Twisted reactor thread. A Twisted TCP4ClientEndpoint +
connectProtocol only completes when the reactor does reactor things,
but the reactor thread is stuck in that loop for the whole proactive
session...

Fixme fix: don't fight the reactor, just drive the relay channel with a plain
old blocking socket, which fits the synchronous execution model.
Channel numbers now come from the command Device identities (channel_N ->
low nibble) instead of the hard coded chan_nr == 1.

Additionally fix two bugs found on the path to scp81 glory:
- TERMINAL RESPONSE device identities are forced to terminal->UICC per
  TS 102 223 6.8.2 (prepare_response() inverts the command identities,
  which for a channel-addressed BIP command yields channel_N->UICC).
- Error responses now build a valid AddlInfoBip cause, prepare_response()
  hard coded empty "additional information" cannot be encoded for a
  BIP error.

And some tests based on real card interactions.

Change-Id: If96c768f2e35c20ea3753e601059410121517b60
2026-09-23 18:01:16 +02:00
Eric Wild 5a54dd9eda sms/smpp-ota-tool: reassemble multi part response SMS
A large OTA response (for example GP GET STATUS app registry) is split by the card
into multiple SMS, each carries a TS 23.040 9.2.3.24 'concatenated short messages'
IE in its UDH. Nothing recombines them, so smpp-ota-tool currently only sees the
first incomplete part.

Add ConcatenatedSmsReassembler that accepts TP-User-Data, buffers parts
by reference number, and returns the reassembled TP-User-Data in the canonical
single-part form. Non-concatenated SMS pass through unchanged.
Both the 8-bit+16-bit references are supported.

A reserved value in the concatenation IE is not an error, these messages
are handed back as is rather than rejected, so the caller can deal with that.

Reassembly leads to a result that looks like a fat single part message the
card could have produced given infinite sms sizes, so the existing decode_resp
path is unaffected.

Feeding those parts to the ota tool needs two more fixes because the card returns
the application response as several SMS via proactive SEND SHORT MESSAGE while
the ENVELOPE SMS-PP DOWNLOAD itself contains the POR without a app R-APDU.
smpplib poll() drains everything, so message_received_handler runs on each:

 - a later status-only response must not overwrite an application response
   already captured, or transceive_apdu finds no last_response_data and raises
 - a response that cannot be decoded is be logged and skipped rather raised
 	out of client.poll() which kills the tool.

Plus tests from a sja5 session.

Incomplete sets are capped (oldest evicted) so they cannot pile up.

Change-Id: I8c81097e607e0d055c4f031bbcc8a74d5c24a0e7
2026-09-23 18:01:16 +02:00
Eric Wild 63d4c447fb transport/smpp2sim: TERMINAL RESPONSE for proactive SEND SHORT MESSAGE
A multi part OTA response (full GP GET STATUS registry or some other fat
response that exceeds one SMS) is delivered as several SMS via proactive
SEND SHORT MESSAGE. The card only gives us another part if it receives a
TERMINAL RESPONSE for the previous one.

Currently smpp2sim Proact.handle_SendShortMessage relays the SMS but
returns None, so the transport falls back to prepare_response(pcmd) with
the ProactiveCommand collection (empty .children) and crashes with
'not enough values to unpack (expected 1, got 0)', dropps the SMPP link,
and never fetches the remaining parts.

Fix: make handle_SendShortMessage return a successful TERMINAL RESPONSE
built from the decoded command so the handshake proceeds.
prepare_response() is extended to handle collection via .decoded
and raises a useful error.

The send_apdu_checksw general_result='FIXME' path is now avoided for
SendShortMessage but remains a problem for other handlers that return
None.

Change-Id: Ib96ce81c4ff093b8a6fc715f79617e95a2dd8433
2026-09-23 18:01:16 +02:00
Philipp Maier 1e41568c12 pySim-shell: use verify_adm command for commandline ADM auth
since the very early days, pySim-shell.py has a commandline option
where the user may supply an ADM pin as commandline parameter.
(-a / --pin-adm and -A --pin-adm-hex) This was introduced to simplify
the usage of pySim-shell.py with shellscripts.

Unfortunately the code that handles those commandline options
duplicates the code of the verify_adm commmand. Fortunately it is
very easy to call pySim-shell commands directly using the CMD2
onecmd_plus_hooks method, so we can just call the verify_adm and
replace the duplicated code with that.

So far we are only able to use ADM1 pins from the commandline, since
we now practically use the verify_adm commnad, we can add another
parameter to allow the verification of ADM as well.

Related: SYS#8239
Change-Id: I7164fad757048774aa7186a84041febde75c351c
2026-09-21 14:56:42 +02:00
Eric Wild 2761d16582 smpp-ota-tool: add --format compact,expanded for TS 102 226 5.2
Add --format expanded flag to send C-APDUs in the expanded remote
application data format.
Each --apdu becomes its own C-APDU TLV, and the tool
logs the full per-command R-APDU list decoded from the Response Scripting
template and warns if the card reports a truncated response.

Default stays 'compact', unchanged.

Change-Id: Idad90756f85bb7e54ef720f6067bb5d6dcff0c42
2026-09-08 14:49:07 +02:00
Eric Wild aeba4a547c ota: add TS 102 226 5.2 Expanded Remote Application data format
TS 102 226 section 5.2 "Command and Response Scripting templates"
The advantage over compact RFM/RAM commands is one C-APDU TLV per command
and one R-APDU TLV per result, so multiple commands return the response
of each one rather than only that of the last.

encode_expanded_cmd() builds the Command Scripting template
decode_expanded_resp() decodes the Response Scripting template into a
Container.

The 'truncated' key must be checked!

OtaDialect.encode_cmd()/decode_resp() now has a remote_format param for
compact and expanded formats, default stays compact,so existing code is
unaffected.

Change-Id: Idec00d16fd1a7d4a7129b2a3b6f0ef37dabcecb7
2026-09-08 14:49:07 +02:00
Eric Wild a8a94eae9c bip: move the BIP relay into pySim.bip
Move the code from pySim-smpp2sim.py to its own file, so it can be properly
extended.
The current file parses argv, opens a reader and starts the Twisted
reactor at import time, so nothing else can import it.

No functional changes yet, improvements follow in later commits.

Change-Id: Ifd8a15684939977d29ea83a6b669daee14484e88
2026-09-08 14:47:41 +02:00
Eric Wild 41e0d532f0 tests: stop test_log from leaking the print callback
PySimLogger.setup() installs a process-global print callback.
PySimLogger_Test sets one, a helper that asserts the message equals a global
expected_message, and never removes it, so from the moment test_log runs,
every PySimLogger message emitted anywhere in the process is checked against
whatever string that global happens to hold.

Fortunately unittest discovery runs modules in sorted order, and today
the PySimLogger users that log during tests all sort before test_log, so
this only breaks as soon as I try to add tests, just like anything else
breaks as soon as I try to use it.

Change-Id: I481e2c443fe0f412380b0f1acf6da5971ffca147
2026-09-07 19:51:23 +02:00
Eric Wild e03530f89a GP: LOAD/STORE DATA chunk size from SCP overhead
SCP.overhead was so far set at construction time (SCP02: 8, SCP03:
s_mode), so the C-MAC length only.
Unfortunately sec lvl >= 3 pads the data field to the cipher block size
before encryption, so the real worst-case overhead is larger,
scc.max_cmd_len (255 - overhead) was too big, and ADF_SD.load()
used a hardcoded chunk_len=240.

Real world issue with a 286 byte CAP + SCP02 + sec lvl 3:
- 240-byte LOAD block is padded to 248,
- encrypted
- gets 8 byte C-MAC appended
-> Lc = 256
That dies with a weird "ValueError: bytes must be in range(0, 256)".
The only "fix" for that was to downgrade the seclevel.

STORE DATA has the same overflow with large max_cmd_len
(247 + padding + MAC = 256 as well).

Therefore the overhead must be properly calculated from the sec level.

While at it adjust the error in case I missed something to get a more
useful ValueError.

Change-Id: Ic208f3959a38896f64fb6ccefb24cc360a3ac3a2
2026-09-07 19:51:23 +02:00
Eric Wild 078ac2bf19 GP: mixed PSK TLS PUT KEY (Amendment B Table 3-13)
AES PSK + DES DEK for scp81, tested with sysmoEUICC1 C2T

Change-Id: I480a9d049a052aa5ae54fe6e2771dba44e89434d
2026-09-07 19:51:23 +02:00
Eric Wild c582b5fee3 GP: fix kcb for non block aligned keys
how encrypt_key() pads a kcv:
len(key) % blocksize bytes
what it should do to actually do it right:
blocksize - len(key) % blocksize
so the plaintext handed to the cipher was only block aligned by luck as
long as the key length happened to be a multiple of half the block size.

And of course decrypt_key() did not invert encrypt_key() at all,
the clear text length of GP CardSpec v2.3 Table 11-70 precedes the
ENCRYPTED kcv, but it was parsed out of the DECRYPTED data, and the
length byte itself was fed to the cipher along with the cryptogram.

Fix this up with a helper and tests so it is actually usable.

Change-Id: I02b4f2ed948c31e1741e40f0226fb49757fa2570
2026-09-07 19:51:23 +02:00
Neels Hofmeyr d4717bd014 ts_31_102.py: EF_SUCI_Calc_Info(TransparentEF): fix len test
while len(foo):

throws an exception when foo == None.
Instead doing

    while foo:

fixes a problem when reading in empty SUCI calc info data, e.g. from
TS48v7.0_SAIP2.3_BERTLV_SUCI_NoRAMRFM.der.

Change-Id: Ia4e2356d0241d7a6ca399ba7e8be7f27ec836104
Jenkins: skip-card-test
2026-08-31 12:25:37 +00:00
Neels Hofmeyr 1cfb0f3da2 typo 'concetenation' in personalization.py
Change-Id: I51345db014335e8a70a7437a9cad5a3e47570a95
Jenkins: skip-card-test
2026-08-31 12:25:37 +00:00
Neels Hofmeyr cb3eb77236 test_configurable_parameters: test less templates
The tests take long and don't need to be this many.
A patch is coming up that adds another profile to test SUCI in GFM,
this patch makes some room for that.

Change-Id: Ib75b6919a3acfddd99bf9baa9b6847ef731b9e67
Jenkins: skip-card-test
2026-08-31 12:25:37 +00:00
Neels Hofmeyr f381255639 saip BatchPersonalization: call rebuild_mandatory_services()
Particular reason: when manipulating the 5G SUCI parameters, the
mandatory services get-identity, profile-a-x25519 and profile-b-p256 may
need to be reconfigured.

In general, it is a good idea to run these checks anyway.

Change-Id: I5e6eef0f1845a25cddb03af8d16c40e305bcdc1f
Jenkins: skip-card-test
2026-08-31 12:25:37 +00:00
Harald Welte d13be84ccd [cosmetic] ts_102_221: update ISO7816 spec references
Change-Id: If4e3842c119d54dfc46e8c9df950b56824ace001
2026-08-22 10:21:38 +02:00
Harald Welte f4eb2f9356 ts_102_221: support EF.ARR record decoding for DF, not just EF
Sadly, the EF.ARR interpretation, specifically that of the AM_DO
depends on whether the rule is for a DF or EF.  As we don't know this,
allow the user to specify what kind of decode they would like, at least
in the file-specific read_arr_record + read_arr_records command.

Change-Id: I05e629e8b7dc705730d7039d2d0170ee24f8f844
2026-08-22 10:21:38 +02:00
Neels Hofmeyr bb362482e8 saip.PES.rebuild_mandatory_services(): set 5G get-identity, profile-a-x25519, profile-b-p256
Related: SYS#8096 SYS#8037
Change-Id: Ibc29c6437c5c92e2b14938b733156536863465c1
Jenkins: skip-card-test
2026-08-19 01:59:34 +02:00
Alexander Couzens 9c77e4ed94 pySim-trace: treat CTRL-C / KeyboardInterrupt as normal abort
Change-Id: Ic75e8454bea4d59d6d7c41f8d9d699dcad56514e
2026-08-10 09:39:45 +00:00
Alexander Couzens ab19049d19 ts_31_102: EF SUCI_Calc_Info: fix decoding empty files
When trying to use `edit_binary_decoded` with an empty file, pysim
runs into a len(None) exception, because hpkl.to_dict()['hnet_pubkey_list'] returns
None.

Can reproduced with a CCC Camp 2023 usim and editing the file.
a000ff..ff (len = 200)

Co-authored-by: Harald Welte <laforge@osmocom.org>
Change-Id: Ib8e322e65dd768bfd49e7a5620a2163f12a74ec7
2026-08-08 19:33:59 +02:00
Philipp Maier 25e43e1540 pySim/ara_m: move code from do_aram_ to static methods
The method do_aram_store_ref_ar_do and do_aram_delete_all, which
are part of the nested AddlShellCommands class, may be moved into
the parent class as a static method, just like the already existing
get_config method.

This makes the functionality re-usable to callers that do not use
the CMD2 API.

Change-Id: Icd1b08ec707dd939bc9e8524d7f9431aa4daae7c
Related: SYS#6959
2026-07-13 12:13:04 +02:00
Neels Hofmeyr 6e10da4c55 saip/personalization: add MncLen configurable parameter
Add a new ConfigurableParameter that represents the MNC length
(2 or 3 digits) in EF.AD (Administrative Data).

Change-Id: I6c600faeab00ffb072acbe94c9a8b2d1397c07d3
Co-authored-by: Vadim Yanitskiy <vyanitskiy@sysmocom.de>
Jenkins: skip-card-test
2026-07-06 17:24:47 +00:00
Vadim Yanitskiy 973d6eb2cc pySim.log: fix E0611: No name 'style' in module 'cmd2'
Change-Id: I191ea56f4c6e4e1916369f69fe2e1653e1d92df1
Fixes: 597f1e0 ("pySim.log, pySim-shell: fix compatibility with cmd2 >= 3.0.0")
2026-07-01 16:47:08 +07:00
Vadim Yanitskiy 597f1e0398 pySim.log, pySim-shell: fix compatibility with cmd2 >= 3.0.0
Some Linux distributions (e.g. Arch Linux) already ship cmd2 3.x.x,
which removed the style()/Fg/Bg API in favor of stylize()/Color.

Add a version guard to select the right API at runtime.
Adjust the upper bound cap in requirements.txt and setup.py.

Change-Id: Ibf2ac7847933296fb06665c87f53ed6e1f315d27
2026-06-26 02:47:45 +07:00
Vadim Yanitskiy 45d37ed959 pySim-shell: drop backwards compat quirks for cmd2 < 2.6.2
Remove version guards for cmd2 < 2.0.0 and < 2.3.0, the Cmd2Compat
and Settable2Compat wrapper classes, and the old fg/bg color API -
none of these are needed since both requirements.txt and setup.py
already mandate cmd2 >= 2.6.2.

Change-Id: Ifd1c484ab66d74323d10e946347daa637cf6f5d8
2026-06-25 22:51:03 +07:00
Harald Welte 757c7d048e setup.py: Align cmd2 minimum version with requirements.txt
As pointed out in the commit-log of Change-Id
I5186f242dbc1b770e3ab8cdca7f27d2a1029fff6 we had different minimum
versions for cmd2 in requirements.txt vs setup.py.  Let's align that.

Change-Id: I71cee0ec3ed2abec68ec567beaab13c868721dad
2026-06-25 21:43:52 +07:00
Alexander Couzens d0e6a1b119 euicc: get_profiles_info: add additional tags
Add definitions for ProfileOwner (decoded),
Notification Configuration Info, SM-DP+ proprietary data,
Profile Policy Rules.

Change-Id: I727dbe34d87a42bb3b526bd7a8accd687d20a208
2026-06-22 17:03:04 +00:00
Alexander Couzens 980282cc12 euicc: extend get_profiles_info to retrieve all known tags
get_profiles_info only request for the default tag list, but
not all tags.
Add --all to the function to request for all known tags.

Change-Id: Ia6878519a480bd625bb1fa2567c1fd2e0e89b071
2026-06-22 17:03:04 +00:00
Neels Hofmeyr 728940efb2 saip: add numeric_base indicator to ConfigurableParameter
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
Jenkins: skip-card-test
2026-06-22 19:56:29 +07:00
Neels Hofmeyr cfe2b94f67 saip SmspTpScAddr.get_values_from_pes: allow empty values
Change-Id: Ibbdd08f96160579238b50699091826883f2e9f5a
Jenkins: skip-card-test
2026-06-22 19:56:29 +07:00
Neels Hofmeyr 861ed0a1d8 add comment about not updating existing key_usage_qualifier
Change-Id: Ie23ae5fde17be6b37746784bf1601b4d0874397a
Jenkins: skip-card-test
2026-06-22 19:56:29 +07:00
Neels Hofmeyr b576e8fcff test_configurable_parameters.py: add tests for new parameters
For:
SmspTpScAddr
MilenageRotation
MilenageXoringConstants
TuakNrOfKeccak

Change-Id: Iecbea14fe31a9ee08d871dcde7f295d26d7bd001
Jenkins: skip-card-test
2026-06-22 19:48:52 +07:00
Neels Hofmeyr 38f93d974b SmspTpScAddr: fix SMSP record length and alpha_id padding
apply_val() was re-encoding the SMSP with the minimum total_len of 28,
which produces a 28-byte body with no alpha_id field.  After a DER
round-trip, the profile machinery re-pads the body to the original
record length using the template's fill pattern, which may not be 0xFF.
Those non-0xFF fill bytes end up in the alpha_id area, and GSM 7-bit
decoding then fails with a KeyError when the modified profile is read
back.

Fix by:
- setting alpha_id = '' so the field is present but empty
- setting f_smsp.rec_len = 42 (28 fixed bytes + 14 bytes of alpha_id
  padding) so the re-encoded body carries 0xFF-padded alpha_id space
  and the efFileSize in the fileDescriptor stays consistent
- passing total_len=f_smsp.rec_len to encode_record_bin() so the
  alpha_id area is actually padded to that length

Change-Id: Ief6e02517f3e96158a2509d763b88aec4bd5a296
Jenkins: skip-card-test
2026-06-21 06:17:54 +07:00
Neels Hofmeyr c5e7e59928 ConfigurableParameter: safer val length check
validate_val() calls len() to check the value against allow_len,
min_len and max_len. len() requires the object to have a __len__()
method, which integers do not — calling len() on an int raises
TypeError.

Fix this by checking for __len__ first: if present, use len(val) as
usual; otherwise fall back to len(str(val)), which gives the number
of decimal digits for integer values.

Change-Id: Ibe91722ed1477b00d20ef5e4e7abd9068ff2f3e4
Jenkins: skip-card-test
2026-06-21 03:46:16 +07:00
Neels Hofmeyr 98af3dd2e9 UppAudit: better indicate exception cause
Change-Id: I4d986b89a473a5b12ed56b4710263b034876a33e
Jenkins: skip-card-test
2026-06-21 03:46:16 +07:00
Neels Hofmeyr e9ff4f3b93 personalization: generate sdkey classes from a list
Change-Id: Ic92ddea6e1fad8167ea75baf78ffc3eb419838c4
Jenkins: skip-card-test
2026-06-21 03:46:16 +07:00
Neels Hofmeyr ce039d69ba saip/param_source: try to not repeat random values
Change-Id: I4fa743ef5677580f94b9df16a5051d1d178edeb0
Jenkins: skip-card-test
2026-06-21 03:46:16 +07:00
Neels Hofmeyr aad92f2b73 param_source: 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
Jenkins: skip-card-test
2026-06-21 03:46:11 +07:00
Neels Hofmeyr 512aba8b1d param_source: use random.SystemRandom as random nr source
Python's random module uses a PRNG (Mersenne Twister) which is
utterly insecure for key generation - it was so far only used for
testing.  Replace it with random.SystemRandom(), which draws from
/dev/urandom and is suitable for generating cryptographic key material.

Change-Id: I6de38c14ac6dd55bc84d53974192509c18d02bfa
Jenkins: skip-card-test
2026-06-21 03:42:06 +07:00
Neels Hofmeyr b5ba274583 add test_param_src.py
Change-Id: I03087b84030fddae98b965e0075d44e04ec6ba5c
Jenkins: skip-card-test
2026-06-21 03:30:02 +07:00
Neels Hofmeyr 4307cffc82 param_source: allow plugging a random implementation (for testing)
Change-Id: Idce2b18af70c17844d6f09f7704efc869456ac39
Jenkins: skip-card-test
2026-06-21 03:30:02 +07:00
Neels Hofmeyr bfdfcad22c personalization: add int as input type for BinaryParameter
Change-Id: I31d8142cb0847a8b291f8dc614d57cb4734f0190
Jenkins: skip-card-test
2026-06-21 03:30:02 +07:00
Neels Hofmeyr ef0a2fcb37 personalization.ConfigurableParameter: fix BytesIO() input
Change-Id: I0ad160eef9015e76eef10baee7c6b606fe249123
Jenkins: skip-card-test
2026-06-21 03:30:02 +07:00
Neels Hofmeyr 3974e96933 add test_configurable_parameters.py
Add ConfigurableParameterTest, which applies each parameter to a real
UPP DER template and reads it back, comparing results against a stored
expected-output snapshot (xo/test_configurable_parameters).

Add TestValidateVal covering validate_val() for Iccid, Imsi, Pin1, Puk1
and K, testing both valid inputs and invalid ones expected to raise
ValueError.

Add TestEnumParam covering the EnumParam methods (validate_val,
map_name_to_val, map_val_to_name, name_normalize, clean_name_str) using
AlgorithmID as the concrete subclass, including fuzzy name matching.

Also add get_value_from_pes() to ConfigurableParameter as a convenience
wrapper around get_values_from_pes() that asserts all returned values
are identical and returns the single result.

Change-Id: Ia55f0d11f8197ca15a948a83a34b3488acf1a0b4
Co-authored-by: Vadim Yanitskiy <vyanitskiy@sysmocom.de>
Jenkins: skip-card-test
2026-06-21 03:30:02 +07:00
Neels Hofmeyr a7c762eb2e ConfigurableParameter: do not magically overwrite the 'name' attribute
The ClassVarMeta metaclass used to derive each ConfigurableParameter's
'name' attribute automatically from the Python class name (via
camel_to_snake()).  Stop doing this, for three reasons:

1) Python class names follow constraints that do not fit the naming
   commonly used in CSV files.  For example, a name like
   "5GS-SUCI-CalcInfo" starts with a digit and contains dashes,
   neither of which is permissible in a class name.

2) Python class names live in their own namespace, distinct from the
   one used to present eSIM parameters to end users.  Deriving the UI
   name from the class name couples these two namespaces together.

   Taken together, (1) and (2) mean that automatic naming both imposes
   class-name constraints on the user-visible names and merges the
   internal Python namespace with the publicly shown one - a layer
   violation from the perspective of UI design.

3) Overriding 'name' from __new__() makes manual naming impossible: a
   subclass that sets 'name = "bar"' as a class attribute would still
   end up with the value computed by the metaclass, which is
   surprising and hard to track down:

       class MySuper(metaclass=...):  # __new__ sets name = 'foo'
           ...
       class MySub(MySuper):
           name = 'bar'
       print(MySub().name)  # 'foo', not 'bar' as one would expect

Change-Id: I6f631444c6addeb7ccc5f6c55b9be3dc83409169
Jenkins: skip-card-test
2026-06-21 03:29:38 +07:00
Neels Hofmeyr 710a27d6cf 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
0x04..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
Jenkins: skip-card-test
2026-06-19 10:37:56 +00:00
Neels Hofmeyr 08f40db8a3 personalization: implement UppAudit and BatchAudit
Change-Id: Iaab336ca91b483ecdddd5c6c8e08dc475dc6bd0a
Jenkins: skip-card-test
2026-06-19 10:37:56 +00:00
kukutyin 4fb393e6ea fix(ts_51_011): fix lifecycle decoding
- implement proper LCS decoding per TS 102 221 / TS 31.101
- previous implementation misclassified multiple states

Related: OS#7018
Change-Id: I8a3bd820b9fbc13c025f8302d1d2eac21686c541
2026-06-08 11:54:32 +00:00
kukutyin ce5da32a75 fix(ts_51_011): apply correct access conditions length
When the access conditions are extracted from resp_bin, the wrong length
is used and only 2 bytes instead of 3 are extracted.

3GPP TS 51.011, section 9.2.1, table below "Response parameters/data
in case of an EF", clearly states that the length should be 3 bytes
(position 9-11)

Related: OS#7018
Change-Id: I410fb58c395beafba8de6d5ab4e71452f424cdf2
2026-06-08 11:54:28 +00:00
Philipp Maier 9ddd235a2c pySim/ts_51_011: rewrite comment for better understanding
The comment reads like that we were applying TS 102.221 here, but we only
mean our internal decoding format. The spec that actually matters here is
TS 51.011. Let's rephrase the comment so that this becomes more clear.

Related: OS#7018
Change-Id: Ie0184eea25f4d9f4baf9ab137c53a926edba2bf8
2026-06-03 08:46:02 +00:00
Philipp Maier 77eb30a782 unittests: add testcases for decode_select_response
A CardProfile class usually contains a static method decode_select_response.
Unfortunately those methods have no unit-test coverage yet. Let's add unit
tests for the decoders in CardProfileSIM and CardProfileUICC.

Related: OS#7018
Change-Id: Id2b5e005d7ad30d56c5c936e612600213620a0ed
2026-06-03 08:46:02 +00:00
YanTong C 2530329ae2 osmo-smdpp.py: use commonpath in transversal check
Use commonpath, as commonprefix allows accessing a sibiling directory
with the same prefix.

Change-Id: I7a42b40aa2bbcd5f0ec99f172503354c6eaa9828
2026-05-27 10:23:13 +02:00
Philipp Maier f9e4291a43 pySim/ara_m, cosmetic: swap --nfc-always and --nfc-never options
The commandline options --nfc-always and --nfc-never appear in the
opposite order when compared to --apdu-never and --apdu-always.

Let's swap the options to make the helpscreen and the code more
consistent.

Change-Id: I7289c3628b1b8dd3eec2f1c8f2132e3015422960
Related: SYS#6959
2026-05-13 15:49:20 +02:00
Philipp Maier 20538775b2 pySim/scp: migrate to pySimLogger
The module scp.py predates the existence of the pySimLogger and still
uses an individually created logger. Let's migrate to pySimLogger to
avoid unexpected effects and to be uniform with the other modules.

Related: SYS#6959
Change-Id: I5db7180f93f116dd2d99c33da264f74ea16a1a37
2026-05-08 17:54:33 +02:00
Philipp Maier ef58c94dfe pySim/filesystem: use pySimLogger instead of print
let's replace the stray print statements with proper logger calls.

Related: SYS#6959
Change-Id: I3a7188ad33706df66b2113e15cc7d06004c9bc39
2026-05-08 17:54:33 +02:00
Philipp Maier 810c51c38f pySim/app: use pySimLogger instead of print
let's replace the stray print statements with proper logger calls.

Related: SYS#6959
Change-Id: I95b4536cc8853e7ba6a5dd573b903dfb85e56b9a
2026-05-08 17:54:33 +02:00
Philipp Maier 66d3b54f92 pySimLogger: fix default log format string
In format string we prepend when we log in verbose mode. We use %(module)s
as format string quaifier. This qualifier is replaced with the name of the
module from where the logger was called. This is mostly equal to the logger
name (__name__) we pass when we create the logger.

However, this is not the behavior we actually want. We want to log the
logger name that we passed when the logger was created. For this, we must
use %(name)s as qualifier.

Related: SYS#6959
Change-Id: I3951a70ad6ce864a7158b093cba46ae9fc1cb5bd
2026-05-08 17:54:33 +02:00
Philipp Maier 7d11f91778 card_key_provider: add a static method to parse --column-keys args
The contents of the --column-keys arguments are currently parsed
in init_card_key_provider. Let's add a static method in
CardKeyFieldCryptor to simplify re-usage of the CardKeyFieldCryptor

Related: SYS#6959
Change-Id: Ic955f271b1de1b1b855b21c82ed10343044e45fa
2026-05-08 17:54:33 +02:00
Philipp Maier 58a324126e card_key_provider: pass CardKeyFieldCryptor to constructor
We currently create the CardKeyFieldCryptor object inside the constructor
of the concrete CardKeyProvider classes. There is currently no problem
with that, but when we create the CardKeyFieldCryptor object first and
then pass it as parameter to the constructor, we gain more flexibility
in case we want to support other CardKeyFieldCryptor variants in the
future.

Related: SYS#6959
Change-Id: If43552740aadacab9126f8a002749a9582eef8f4
2026-05-08 17:54:33 +02:00
Philipp Maier 3cd5c41fb4 card_key_provider: move boiler-plate code into helper functions
in pySim-shell.py we add the commandline options for the card key
provider and do the setup accordingly. Let's put this boilerplate
code into helper functions instead, so that we can re-use it in
other pySim programs as well. Let's use pySim.transport as a
pattern.

Related: SYS#6959
Change-Id: I6d095cbb644e608f4a751a1d0749b1484cdc781d
2026-05-08 17:54:33 +02:00
Philipp Maier 593bfa0911 ts_51_011/EF.SMSP: fix handling of 'alpha_id' field
The field 'alpha_id' is technically not an optional field, even though
the specification describes it as optional. Once the card manufacturer
decides that the field should be present, it must be always present and
vice versa.

(see code comment for a more detailed description)

Related: SYS#7765
Change-Id: I0ec99b2648b22c56f9145345e4cd8776f9217701
2026-04-29 19:39:14 +00:00
Philipp Maier 8fa7727a14 pySim-prog/cards: fix programming of EF.SMSP
The legacy code found in legacy/cards.py does not use the modern
construct based encoder (pySim-read uses it). The card classes either
use their own implementation of update_smsp or use the generic method
provided by the SimCard class. The latter one is true for FairwavesSIM
and WavemobileSim.

Unfortunately the implementation found in the SimCard is wrong. It
adds padding at the end of the file instead of the beginning. This
completely messes up the contents of EF.SMSP for the cards using this
method. To fix this, let's use the leftpad feature provided by
the update_record. This will ensure a correct alignment of the file
contents.

Related: SYS#7765
Change-Id: Ie112418f1f1461762d61365d3863181ca6be7245
2026-04-29 19:39:14 +00:00
Philipp Maier f1609424de pySim/transport: fix GET RESPONSE behaviour
The current behavior we implement in the method __send_apdu_T0 is
incomplete. Some details discussed in ETSI TS 102 221,
section 7.3.1.1.4, clause 4 seem to be not fully implemented. We
may also end up sending a GET RESPONSE in other APDU cases than
case 4 (the only case that uses the GET RESPONSE command).

Related: OS#6970
Change-Id: I26f0566af0cdd61dcc97f5f502479dc76adc37cc
2026-04-29 19:34:27 +00:00
Neels Hofmeyr 1167b65e2a comment in uicc.py on Security Domain Keys: add SCP81
Change-Id: Ib0205880f58e78c07688b4637abd5f67ea0570d1
Jenkins: skip-card-test
2026-04-25 05:15:14 +07:00
Neels Hofmeyr cd4b01f67e 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
Jenkins: skip-card-test
2026-04-25 05:15:14 +07:00
Neels Hofmeyr 393de033d3 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
Jenkins: skip-card-test
2026-04-25 05:15:14 +07:00
Neels Hofmeyr 5f1c7d603c 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
Jenkins: skip-card-test
2026-04-25 05:15:14 +07:00
Neels Hofmeyr d7072e9263 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
Jenkins: skip-card-test
2026-04-25 05:15:14 +07:00
Neels Hofmeyr ac593bb14d 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
Jenkins: skip-card-test
2026-04-25 05:15:14 +07:00
Neels Hofmeyr a95622a022 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
Jenkins: skip-card-test
2026-04-25 05:15:14 +07:00
Vadim Yanitskiy 03b58985a5 tests: pySim-smpp2sim_test.sh: fix copy-pasted comment
Change-Id: I8167c6a3251bb6755810c96075010e920ceee8ac
Jenkins: skip-card-test
2026-04-23 23:54:10 +07:00
Vadim Yanitskiy cc71dbf899 contrib/jenkins.sh: add setup_venv()
Reduece code duplication by factoring out virtualenv setup and
activation into a shell function.

Change-Id: Ibb193d12d5502c78104ef53badc6037f08e92df1
2026-04-23 23:54:00 +07:00
Vadim Yanitskiy aafc8d51c3 contrib/jenkins.sh: separate JOB_TYPE for card tests
A separate job gives us a possibility to skip tests requiring physical
cards for specific commits that do not touch the core logic.  See the
related commits in osmo-ci.git.

Change-Id: If76d812ee43b7eb3b57fdc660c60bf31fbff5b16
Related: osmo-ci.git Ia48d1b468f65d7c2e6b4128eeac36d0f3d03c45e
Related: osmo-ci.git I986d88545f64e13cd571ba9ff56bc924822e39a0
2026-04-23 23:52:55 +07:00
Philipp Maier c50f4b4a02 requirements: ensure safe version of PyYAML >= 5.4 (CVE-2020-1747)
PyYAML versions 5.1–5.3.1 are vulnerable to CVE-2020-1747, which allows
arbitrary code execution through yaml.FullLoader. While PyYAML 5.4+
patches this, the dependency specification (pyyaml >= 5.1) doesn't
guarantee a safe version. Let's increase the requirement to version
5.4 to ensure a safe version of is used.

This patch is based on suggestions from:
"YanTong C <chyeyantong03@gmail.com>"

Change-Id: I901c76c59e9c1bab030eab81038e04a475b32510
2026-04-16 11:01:19 +00:00
YanTong C 816b31eb07 pySim-prog: fix Insecure PRNG for SIM Authentication Keys (CWE-338)
Root Cause:
pySim-prog.py uses Python's random module (Mersenne Twister MT19937) to
generate Ki and OPC — the root authentication keys for SIM cards. MT19937
is a deterministic PRNG that is not cryptographically secure. Its internal
state (624 × 32-bit words, 19,937 bits) can be fully recovered after
observing 624 consecutive outputs.

Impact:
1. SIM Card Cloning: An attacker who determines the PRNG state can predict
all Ki/OPC values generated before and after. With these keys, SIM cards
can be cloned.
2. Network Authentication Bypass: Ki/OPC are used in the Milenage algorithm
for 3G/4G/5G authentication. Predictable keys mean an attacker can
authenticate as any subscriber whose SIM was provisioned with the weak RNG.
3. Batch Compromise: In bulk provisioning scenarios (pySim-prog's primary
use case), hundreds or thousands of SIMs may be programmed sequentially.
Compromising one batch means recovering the PRNG state to predict all keys.

Fix:
Replace random.randrange() with os.urandom()

Change-Id: Id3e00d3ec5386f17c1525cacfc7d3f5bba43381f
2026-04-15 13:50:11 +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
82 changed files with 9963 additions and 825 deletions
+1
View File
@@ -3,6 +3,7 @@
/docs/_* /docs/_*
/docs/generated /docs/generated
/docs/filesystem.rst
/.cache /.cache
/.local /.local
/build /build
+1 -1
View File
@@ -97,7 +97,7 @@ Please install the following dependencies:
- pyscard - pyscard
- pyserial - pyserial
- pytlv - pytlv
- pyyaml >= 5.1 - pyyaml >= 5.4
- smpp.pdu (from `github.com/hologram-io/smpp.pdu`) - smpp.pdu (from `github.com/hologram-io/smpp.pdu`)
- termcolor - termcolor
+1 -4
View File
@@ -285,10 +285,7 @@ if __name__ == '__main__':
option_parser.add_argument("--admin", action='store_true', help="perform action as admin", default=False) option_parser.add_argument("--admin", action='store_true', help="perform action as admin", default=False)
opts = option_parser.parse_args() opts = option_parser.parse_args()
PySimLogger.setup(print, {logging.WARN: "\033[33m"}) PySimLogger.setup(print, {logging.WARN: "\033[33m"}, opts.verbose)
if (opts.verbose):
PySimLogger.set_verbose(True)
PySimLogger.set_level(logging.DEBUG)
# Open CSV file # Open CSV file
cr = open_csv(opts) cr = open_csv(opts)
+20 -14
View File
@@ -10,6 +10,11 @@
export PYTHONUNBUFFERED=1 export PYTHONUNBUFFERED=1
setup_venv() {
virtualenv -p python3 venv --system-site-packages
. venv/bin/activate
}
if [ ! -d "./tests/" ] ; then if [ ! -d "./tests/" ] ; then
echo "###############################################" echo "###############################################"
echo "Please call from pySim-prog top directory" echo "Please call from pySim-prog top directory"
@@ -23,8 +28,7 @@ fi
case "$JOB_TYPE" in case "$JOB_TYPE" in
"test") "test")
virtualenv -p python3 venv --system-site-packages setup_venv
. venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
pip install pyshark pip install pyshark
@@ -32,23 +36,27 @@ case "$JOB_TYPE" in
# Execute automatically discovered unit tests first # Execute automatically discovered unit tests first
python -m unittest discover -v -s tests/unittests python -m unittest discover -v -s tests/unittests
# Run pySim-prog integration tests (requires physical cards)
cd tests/pySim-prog_test/
./pySim-prog_test.sh
cd ../../
# Run pySim-trace test # Run pySim-trace test
tests/pySim-trace_test/pySim-trace_test.sh tests/pySim-trace_test/pySim-trace_test.sh
;;
"card-test") # tests requiring physical cards
setup_venv
# Run pySim-shell integration tests (requires physical cards) pip install -r requirements.txt
# Run pySim-prog integration tests
cd tests/pySim-prog_test/
./pySim-prog_test.sh
cd ../../
# Run pySim-shell integration tests
python3 -m unittest discover -v -s ./tests/pySim-shell_test/ python3 -m unittest discover -v -s ./tests/pySim-shell_test/
# Run pySim-smpp2sim test # Run pySim-smpp2sim test
tests/pySim-smpp2sim_test/pySim-smpp2sim_test.sh tests/pySim-smpp2sim_test/pySim-smpp2sim_test.sh
;; ;;
"distcheck") "distcheck")
virtualenv -p python3 venv --system-site-packages setup_venv
. venv/bin/activate
pip install . pip install .
pip install pyshark pip install pyshark
@@ -61,8 +69,7 @@ case "$JOB_TYPE" in
# Print pylint version # Print pylint version
pip3 freeze | grep pylint pip3 freeze | grep pylint
virtualenv -p python3 venv --system-site-packages setup_venv
. venv/bin/activate
pip install . pip install .
@@ -80,8 +87,7 @@ case "$JOB_TYPE" in
contrib/*.py contrib/*.py
;; ;;
"docs") "docs")
virtualenv -p python3 venv --system-site-packages setup_venv
. venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
+524
View File
@@ -0,0 +1,524 @@
#!/usr/bin/env python3
"""TLS-PSK HTTP Remote Administration Server for SCP81 / RAM over HTTP"""
# (C) 2026 by sysmocom - s.f.m.c. GmbH
# All Rights Reserved
#
# Author: Eric Wild <ewild@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/>.
# The card (specifically a SD supporting SCP81) is the TLS client:
# - it opens a TCP connection to this server,
# - performs a TLS handshake authenticated with a PSK,
# - and then drives the HTTP admin loop of to fetch remote APDU command strings
# - and posts back their responses.
# This program is the server side of that exchange, it:
# - accepts the PSK-TLS connection,
# - hands the card a queue of commands
# - and logs the decoded responses.
#
# The two TS 102 226 annex B figure B.1 administration modes are supported over the
# same session, selected with --mode:
# ram GP Amendment B RAM:
# command is handled by (--targeted-application) a SD
# rfm ETSI TS 102 226 RFM/RAM:
# command is routed to the Receiving/RFM Application specified by
# --targeted-application, for example UICC-filesystem/USIM-ADF RFM app.
#
# Remote APDU command/response bodies use the Expanded Remote Application
# data format.
#
import ssl
import socket
import logging
import argparse
import threading
from pathlib import Path
from typing import List, Optional, Callable, Dict, Tuple
from osmocom.utils import h2b, b2h
from pySim.ota import encode_expanded_cmd, decode_expanded_resp
logger = logging.getLogger(Path(__file__).stem)
# Amendment B section 3.4
ADMIN_PROTOCOL = 'globalplatform-remote-admin/1.0'
CT_COMMAND = 'application/vnd.globalplatform.card-content-mgt;version=1.0'
CT_RESPONSE = 'application/vnd.globalplatform.card-content-mgt-response;version=1.0'
# TS 102 226 annex B, figure B.1 RFM/RAM over HTTPS content types
CT_RFM_COMMAND = 'application/vnd.etsi.scp.command-data;version=1.0'
CT_RFM_RESPONSE = 'application/vnd.etsi.scp.response-data;version=1.0'
MODE_CONTENT_TYPE = {
'ram': CT_COMMAND, # GP Amendment B RAM: target = a Security Domain
'rfm': CT_RFM_COMMAND, # ETSI TS 102 226 RFM/RAM: target = an application
}
# Amendment B Table 3-2. The 3DES and NULL suites are left out and can be enabled with
# --ciphers / --seclevel.
DEFAULT_CIPHERS = ':'.join([
'PSK-AES128-CBC-SHA256', # TLS_PSK_WITH_AES_128_CBC_SHA256, TLS 1.2
'PSK-AES128-CBC-SHA', # TLS_PSK_WITH_AES_128_CBC_SHA, TLS 1.0/1.1
])
TLS_VERSION_MAP = {
'1.0': ssl.TLSVersion.TLSv1,
'1.1': ssl.TLSVersion.TLSv1_1,
'1.2': ssl.TLSVersion.TLSv1_2,
'1.3': ssl.TLSVersion.TLSv1_3,
}
def format_aid(aid: str) -> str:
"""AID -> //aid/<RID>/<PIX> for X-Admin-Targeted-Application from Amendment B section 3.4.2
First 5 bytes RID, the PIX the remainder, string in //aid/ notation is passed through."""
if aid.startswith('//aid/'):
return aid
aid = aid.replace(' ', '').lower()
if len(aid) < 10:
raise ValueError('AID %r is shorter than the 5 byte RID' % aid)
rid, pix = aid[:10], aid[10:]
return '//aid/%s/%s' % (rid, pix)
def make_ssl_context(psk: bytes, identity: str, *,
ciphers: str = DEFAULT_CIPHERS,
min_tls: str = '1.2', max_tls: str = '1.3',
seclevel: Optional[int] = None,
identity_hint: Optional[str] = None,
allow_any_identity: bool = False,
extra_psks: Optional[Dict[str, bytes]] = None) -> ssl.SSLContext:
"""PSK SSLContext, resolvesg the key from the client psk_identity
extra_psks can carry additional identity->key mappings.
allow_any_identity can be used for debugging
"""
# the PSK callback must return immutable bytes, h2b() gives a bytearray
psk = bytes(psk)
keymap: Dict[str, bytes] = {identity: psk}
if extra_psks:
keymap.update({k: bytes(v) for k, v in extra_psks.items()})
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.minimum_version = TLS_VERSION_MAP[min_tls]
ctx.maximum_version = TLS_VERSION_MAP[max_tls]
cipher_str = ciphers
if seclevel is not None:
# @SECLEVEL=0 is to enable NULL/3DES/legacy PSK suites
cipher_str = '%s:@SECLEVEL=%d' % (ciphers, seclevel)
if cipher_str:
ctx.set_ciphers(cipher_str)
def psk_server_callback(client_identity: Optional[str]) -> bytes:
if allow_any_identity:
logger.info('PSK handshake: identity=%r (ACEPTING ANY!)', client_identity)
return psk
key = keymap.get(client_identity)
if key is None:
logger.warning('PSK handshake: unknown identity %r (known: %r) -> rejecting',
client_identity, list(keymap.keys()))
return b'' # empty PSK aborts handshake
logger.info('PSK handshake: identity=%r resolved', client_identity)
return key
ctx.set_psk_server_callback(psk_server_callback, identity_hint=identity_hint)
return ctx
class HttpRequest:
"""A parsed HTTP request (request line + headers + body)."""
__slots__ = ('method', 'uri', 'version', 'headers', 'body')
def __init__(self, method: str, uri: str, version: str,
headers: Dict[str, str], body: bytes):
self.method = method
self.uri = uri
self.version = version
self.headers = headers # lower cased field names
self.body = body
def get(self, name: str, default=None) -> Optional[str]:
return self.headers.get(name.lower(), default)
# http.client bound on a single HTTP line.
MAX_LINE = 65536
def _read_line(rfile) -> bytes:
"""readline() with a bound. reaching the bound without a
terminator means the card is out of sync somehow, not that the line is long."""
line = rfile.readline(MAX_LINE)
if line and not line.endswith(b'\n'):
raise ValueError('HTTP line longer than %u bytes' % MAX_LINE)
return line
def _read_chunked_body(rfile) -> bytes:
"""Read a Transfer-Encoding: chunked body. Amendment B section 3.4.1 lets
the card send its response string with either a Content-Length or chunked"""
out = bytearray()
while True:
size_line = _read_line(rfile)
if not size_line:
break
size = int(size_line.split(b';', 1)[0].strip() or b'0', 16)
if size == 0:
# consume trailer headers up to the terminating blank line
while _read_line(rfile) not in (b'\r\n', b'\n', b''):
pass
break
chunk = rfile.read(size)
if len(chunk) != size:
raise ValueError('chunked body ended after %u of %u bytes' % (len(chunk), size))
out += chunk
_read_line(rfile) # trailing CRLF after the chunk data
return bytes(out)
def read_http_request(rfile, send: Optional[Callable[[bytes], None]] = None) -> Optional[HttpRequest]:
"""Read one HTTP request from a buffered binary reader or None when closed"""
request_line = _read_line(rfile)
if not request_line:
return None
parts = request_line.rstrip(b'\r\n').decode('iso-8859-1').split(' ')
if len(parts) < 3:
raise ValueError('Malformed HTTP request line: %r' % request_line)
method, uri, version = parts[0], parts[1], parts[2]
headers: Dict[str, str] = {}
while True:
line = _read_line(rfile)
if line in (b'\r\n', b'\n', b''):
break
name, _, value = line.rstrip(b'\r\n').decode('iso-8859-1').partition(':')
headers[name.strip().lower()] = value.strip()
# Expect: 100-continue waits for the response before it sends the body,
# and RFC 2616 8.2.3 (Amendment B references RFC 2616 as [HTTP])
# requires the server to send it. Amendment B 3.4.1 does not mention this
# header, tho, might be useless.
if send and '100-continue' in headers.get('expect', '').lower():
logger.info('-> 100 Continue ')
send(b'HTTP/1.1 100 Continue\r\n\r\n')
body = b''
te = headers.get('transfer-encoding', '').lower()
if 'chunked' in te:
body = _read_chunked_body(rfile)
elif 'content-length' in headers:
n = int(headers['content-length'])
if n:
body = rfile.read(n)
return HttpRequest(method, uri, version, headers, body)
def build_http_response(status_line: str, headers: List[Tuple[str, str]],
body: bytes = b'') -> bytes:
"""Serialise HTTP response. status_line 'HTTP/1.1 200 OK'."""
lines = [status_line]
lines += ['%s: %s' % (name, value) for name, value in headers]
head = ('\r\n'.join(lines) + '\r\n\r\n').encode('iso-8859-1')
return head + body
def format_decoded_response(dec) -> str:
"""hand over the data"""
bits = ['%u command(s) executed' % dec.number_of_commands]
for i, c in enumerate(dec.commands):
data = c.response_data or '-'
bits.append(' R-APDU[%u]: SW=%s data=%s' % (i, c.status_word, data))
if dec.get('truncated'):
bits.append(' TRUNCATED: an R-APDU returned SW 62F1, so the card cut the response data '
'short and stopped executing the rest of the script ') # TS 102 226 5.2.1.1
if dec.bad_format is not None:
bits.append(' bad-format: %s' % dec.bad_format)
if dec.immediate_action_response is not None:
bits.append(' immediate-action-response: %s' % dec.immediate_action_response)
if dec.script_chaining_response is not None:
bits.append(' script-chaining-response: %s' % dec.script_chaining_response)
return '\n'.join(bits)
class AdminSession:
def __init__(self, command_bodies: List[bytes],
next_uri: Optional[str] = None,
targeted_application: Optional[str] = None,
on_response: Optional[Callable[[object], None]] = None,
content_type: str = CT_COMMAND):
self.pending: List[bytes] = list(command_bodies)
self.next_uri = next_uri # None -> echo the request URI
self.targeted_application = targeted_application
self.on_response = on_response
self.content_type = content_type # Content-Type for the command body
self.responses: List[object] = [] # decoded Containers, in order
def record_response(self, dec) -> None:
self.responses.append(dec)
if self.on_response:
self.on_response(dec)
def run_admin_loop(rfile, send: Callable[[bytes], None], session: AdminSession) -> AdminSession:
"""Drive the admin loop for one connection.
Just keep answering the card POST requests with the next queued command
(200 OK + Expanded command body) until the queue is empty, end session with 204 No Content."""
while True:
req = read_http_request(rfile, send)
if req is None:
logger.info('connection closed by card')
return session
if req.method != 'POST':
logger.warning('unexpected method %s %s -> 405', req.method, req.uri)
send(build_http_response('HTTP/1.1 405 Method Not Allowed',
[('X-Admin-Protocol', ADMIN_PROTOCOL),
('Connection', 'close')]))
return session
proto = req.get('x-admin-protocol')
if proto and proto != ADMIN_PROTOCOL:
logger.warning('card X-Admin-Protocol=%r (expected %r)', proto, ADMIN_PROTOCOL)
status = req.get('x-admin-script-status')
resume = req.get('x-admin-resume')
logger.info('POST %s from=%r status=%r resume=%r body=%uB',
req.uri, req.get('x-admin-from'), status, resume, len(req.body))
# section 3.4.1:
# - body with "X-Admin-Script-Status: ok" carries the previous command
# response string (Expanded Remote response format);
# - other status values carry no body ().
if req.body:
# Expanded Remote response:
# - GP Amd B 'card-content-mgt-response'
# - ETSI 'scp.response-data'
logger.debug(' response Content-Type=%r raw body (%uB): %s',
req.get('content-type'), len(req.body), b2h(req.body))
if status in (None, 'ok'):
try:
dec = decode_expanded_resp(req.body)
session.record_response(dec)
logger.info('card response:\n%s', format_decoded_response(dec))
except Exception as e:
logger.error('failed to decode response body %s: %s', b2h(req.body), e)
else:
logger.warning('body present with status=%r; ignoring', status) # section 3.4.1
elif status and status != 'ok':
logger.info('card reported script-status=%r (no response body)', status)
if session.pending:
body = session.pending.pop(0)
next_uri = session.next_uri or req.uri
headers = [('X-Admin-Protocol', ADMIN_PROTOCOL),
('X-Admin-Next-URI', next_uri),
('Content-Type', session.content_type)]
if session.targeted_application:
headers.append(('X-Admin-Targeted-Application', session.targeted_application))
headers.append(('Content-Length', str(len(body))))
logger.info('-> 200 OK, next command (%uB): %s', len(body), b2h(body))
send(build_http_response('HTTP/1.1 200 OK', headers, body))
else:
# section 3.4.2: No more commands, end session
# No Content-Type or body for 204
logger.info('-> 204 No Content, ending administration session')
send(build_http_response('HTTP/1.1 204 No Content',
[('X-Admin-Protocol', ADMIN_PROTOCOL),
('Connection', 'close')]))
return session
class Scp81AdminServer:
"""Threaded TLS-PSK server that runs the Amendment B admin loop against each
connecting card."""
def __init__(self, host: str, port: int, ssl_ctx: ssl.SSLContext,
command_bodies: List[bytes],
next_uri: Optional[str] = None,
targeted_application: Optional[str] = None,
on_response: Optional[Callable[[object], None]] = None,
on_session_end: Optional[Callable[[AdminSession], None]] = None,
content_type: str = CT_COMMAND):
self.host = host
self.port = port
self.ssl_ctx = ssl_ctx
self.command_bodies = command_bodies
self.next_uri = next_uri
self.targeted_application = targeted_application
self.content_type = content_type
self.on_response = on_response
self.on_session_end = on_session_end
self._sock: Optional[socket.socket] = None
self._stop = threading.Event()
def bind(self) -> int:
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self._sock.bind((self.host, self.port))
self._sock.listen(5)
self._sock.settimeout(0.5)
self.port = self._sock.getsockname()[1]
return self.port
def serve_forever(self) -> None:
if self._sock is None:
self.bind()
logger.info('SCP81 admin server listening on %s:%u (%u command(s) queued)',
self.host, self.port, len(self.command_bodies))
while not self._stop.is_set():
try:
conn, addr = self._sock.accept()
except socket.timeout:
continue
except OSError:
break
threading.Thread(target=self._handle, args=(conn, addr), daemon=True).start()
def shutdown(self) -> None:
self._stop.set()
if self._sock is not None:
self._sock.close()
def _handle(self, conn: socket.socket, addr) -> None:
try:
tls = self.ssl_ctx.wrap_socket(conn, server_side=True)
except (ssl.SSLError, OSError) as e:
logger.warning('TLS-PSK handshake with %s failed: %s', addr, e)
try:
conn.close()
except OSError:
pass
return
logger.info('TLS-PSK established with %s: %s / %s', addr, tls.version(), tls.cipher())
session = AdminSession(self.command_bodies, next_uri=self.next_uri,
targeted_application=self.targeted_application,
on_response=self.on_response,
content_type=self.content_type)
try:
rfile = tls.makefile('rb')
run_admin_loop(rfile, tls.sendall, session)
except (ssl.SSLError, OSError, ValueError) as e:
logger.warning('session with %s aborted: %s', addr, e)
finally:
try:
tls.close()
except OSError:
pass
logger.info('session with %s ended: %u response(s) collected', addr, len(session.responses))
if self.on_session_end:
self.on_session_end(session)
def build_command_bodies(apdus: List[bytes], batch: bool = False,
length_coding: str = 'definite') -> List[bytes]:
"""wrpa apdus
each C-APDU -> one cmd message + one HTTP response per APDU
batch=True -> all C-APDUs in one Command Scripting template.
length_coding selects the definite or indefinite Command Scripting template."""
if not apdus:
return []
if batch:
return [encode_expanded_cmd(apdus, length_coding=length_coding)]
return [encode_expanded_cmd(a, length_coding=length_coding) for a in apdus]
def main():
parser = argparse.ArgumentParser(
description='TLS-PSK HTTP Remote Administration Server for SCP81 / RAM over HTTP')
parser.add_argument('--host', default='0.0.0.0', help='Host/IP to bind to (default: 0.0.0.0)')
parser.add_argument('--port', type=int, default=8443, help='TCP port to bind to (default: 8443)')
parser.add_argument('--psk', required=True,
help='PSK TLS key, Amendment B key type 85 as hex')
parser.add_argument('--psk-identity', required=True,
help='Expected PSK identity string presented by the card')
parser.add_argument('--psk-identity-hint', default=None,
help='Optional PSK identity hint to send to the card (default: none)')
parser.add_argument('--allow-any-identity', action='store_true',
help='DEBUG: Accept any psk_identity')
parser.add_argument('--ciphers', default=DEFAULT_CIPHERS,
help='OpenSSL cipher string for TLS<=1.2')
parser.add_argument('--min-tls', default='1.2', choices=sorted(TLS_VERSION_MAP),
help='Minimum TLS version (default: 1.2)')
parser.add_argument('--max-tls', default='1.3', choices=sorted(TLS_VERSION_MAP),
help='Maximum TLS version (default: 1.3)')
parser.add_argument('--seclevel', type=int, default=None,
help='OpenSSL @SECLEVEL to force (0 to enable NULL/3DES/legacy PSK)')
parser.add_argument('--uri', default=None,
help='X-Admin-Next-URI to hand the card (default: request URI)')
parser.add_argument('--mode', choices=sorted(MODE_CONTENT_TYPE), default='ram',
help='"ram" = GP Amendment B RAM to a SD (default), '
'"rfm" = TS 102 226 RFM/RAM to the --targeted-application.')
parser.add_argument('--targeted-application', default=None,
help='X-Admin-Targeted-Application AID (hex). '
'Required by --mode rfm, optional for --mode ram')
parser.add_argument('--length-coding', choices=('definite', 'indefinite'), default='definite',
help='Expanded format length coding "definite" "indefinite"')
parser.add_argument('--apdu', action='append', default=[], metavar='HEX',
help='one of many C-APDU (hex) to send, executed in order')
parser.add_argument('--apdu-file', default=None,
help='File with one C-APDU (hex) per line to push (# comments allowed)')
parser.add_argument('--batch', action='store_true',
help='All C-APDUs in one large command message')
parser.add_argument('--raw-cmd', action='append', default=[], metavar='HEX',
help='Debug, raw command')
parser.add_argument('-v', '--verbose', action='store_true', help='enable debug output')
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO,
format='%(asctime)s %(levelname)s %(message)s',
datefmt='%Y-%m-%d %H:%M:%S')
if args.mode == 'rfm' and not args.targeted_application:
parser.error('--mode rfm requires --targeted-application <RFM Application AID>')
content_type = MODE_CONTENT_TYPE[args.mode]
apdus: List[bytes] = [h2b(a) for a in args.apdu]
if args.apdu_file:
for line in Path(args.apdu_file).read_text().splitlines():
line = line.split('#', 1)[0].strip()
if line:
apdus.append(h2b(line))
command_bodies = build_command_bodies(apdus, batch=args.batch,
length_coding=args.length_coding)
command_bodies += [h2b(r) for r in args.raw_cmd]
if not command_bodies:
logger.warning('no C-APDUs: the server will answer the first POST with 204...')
targeted = format_aid(args.targeted_application) if args.targeted_application else None
logger.info('mode=%s content-type=%s length-coding=%s targeted-application=%s',
args.mode, content_type, args.length_coding, targeted or '(none)')
ssl_ctx = make_ssl_context(h2b(args.psk), args.psk_identity,
ciphers=args.ciphers,
min_tls=args.min_tls, max_tls=args.max_tls,
seclevel=args.seclevel,
identity_hint=args.psk_identity_hint,
allow_any_identity=args.allow_any_identity)
server = Scp81AdminServer(args.host, args.port, ssl_ctx, command_bodies,
next_uri=args.uri, targeted_application=targeted,
content_type=content_type)
try:
server.serve_forever()
except KeyboardInterrupt:
logger.info('shutting down')
server.shutdown()
if __name__ == '__main__':
main()
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""scp81_trigger.py -- build the OTA packet that asks the card to open an SCP81 admin session."""
# (C) 2026 by sysmocom - s.f.m.c. GmbH
# All Rights Reserved
#
# Author: Eric Wild <ewild@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/>.
# Prints the apdu line for AdmSessTriggerParams TLV as the sms secured data, Expanded RFM mode,
# to be fed into pysim_shell.py
#
# security params supplied either
# - in the trigger
# - from the cards data object,
# trigger wins when both are present.
# --no-sec omits them from the trigger so the stored ones are used.
#
# example params:
# --psk-id 'PSK Identity 123' --kvn 0x41 --kid-ref 5
# --ip 127.0.0.1 --port 8080 --buffer 512
# --host 172.96.0.1 --uri '/server/adminagent?cmd=1'
import argparse
import sys
from osmocom.utils import b2h # noqa: E402
from pySim.cat import (sms_pp_download_envelope, BearerDescription, # noqa: E402
BufferSize, UiccTransportLevel, OtherAddress)
from pySim.global_platform.http import (AdmSessTriggerParams, AdmSessionParams, # noqa: E402
SecurityParams, HttpPostParams, RasConnectionParams,
AdminHostParam, AdminUriParam)
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--psk-id", help="PSK identity for ClientHello (required, unless --no-sec)")
ap.add_argument("--kvn", type=lambda s: int(s, 0), help="key version of the PSK (required, unless --no-sec)")
ap.add_argument("--kid-ref", type=lambda s: int(s, 0), help="PSK key id (required, unless --no-sec)")
ap.add_argument("--host", help="HTTP Host header (required, unless --no-http)")
ap.add_argument("--uri", help="HTTP request URI (required, unless --no-http)")
ap.add_argument("--ip", help="administration server address, BIP (required, unless --no-conn)")
ap.add_argument("--port", type=int, help="administration server port (required, unless --no-conn)")
ap.add_argument("--buffer", type=int, help="BIP buffer size (required, unless --no-conn)")
ap.add_argument("--no-conn", action="store_true", help="omit the connection params (tag 0x84)")
ap.add_argument("--no-sec", action="store_true", help="omit the security params (tag 0x85)")
ap.add_argument("--no-http", action="store_true", help="omit the HTTP POST params (tag 0x89)")
args = ap.parse_args()
missing = []
if not args.no_conn:
missing += [n for n in ('ip', 'port', 'buffer') if getattr(args, n) is None]
if not args.no_sec:
missing += [n for n in ('psk_id', 'kvn', 'kid_ref') if getattr(args, n) is None]
if not args.no_http:
missing += [n for n in ('host', 'uri') if getattr(args, n) is None]
if missing:
ap.error("pass every value required: %s." % " ".join("--" + n.replace('_', '-') for n in missing))
session = []
if not args.no_conn:
session.append(RasConnectionParams(children=[
BearerDescription(decoded={'bearer_type': 'default', 'bearer_parameters': ''}),
BufferSize(decoded=args.buffer),
UiccTransportLevel(decoded={'protocol_type': 'tcp_uicc_client_remote',
'port_number': args.port}),
OtherAddress(decoded={'type_of_address': 'ipv4',
'address': bytes(int(b) for b in args.ip.split("."))})]))
if not args.no_sec:
session.append(SecurityParams(decoded={'psk_id': args.psk_id.encode(), 'kvn': args.kvn,
'kid': args.kid_ref, 'sha_type': None}))
if not args.no_http:
session.append(HttpPostParams(children=[AdminHostParam(decoded=args.host),
AdminUriParam(decoded=args.uri)]))
trig = AdmSessTriggerParams(children=[AdmSessionParams(children=session)]).to_tlv()
# stderr for logs, stdout for data
print("# trigger TLV %d B %s" % (len(trig), trig.hex()), file=sys.stderr)
print("# %-13s %d B %s" % ("secured data", len(trig), trig.hex()), file=sys.stderr)
print(trig.hex())
return 0
if __name__ == "__main__":
sys.exit(main())
+77 -19
View File
@@ -24,7 +24,8 @@ import smpplib.gsm
import smpplib.client import smpplib.client
import smpplib.consts import smpplib.consts
import time import time
from pySim.ota import OtaKeyset, OtaDialectSms, OtaAlgoCrypt, OtaAlgoAuth, CNTR_REQ, RC_CC_DS, POR_REQ from pySim.ota import OtaKeyset, OtaDialectSms, OtaAlgoCrypt, OtaAlgoAuth, OtaCheckError, CNTR_REQ, RC_CC_DS, POR_REQ
from pySim.sms import ConcatenatedSmsReassembler
from pySim.utils import b2h, h2b, is_hexstr from pySim.utils import b2h, h2b, is_hexstr
from pathlib import Path from pathlib import Path
@@ -70,6 +71,8 @@ option_parser.add_argument("--por-req", choices=POR_REQ.decmapping.values(), def
option_parser.add_argument('--src-addr', default='12', type=str, help='SMS source address (MSISDN)') 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('--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('--timeout', default=10, type=int, help='Maximum response waiting time')
option_parser.add_argument('--format', choices=['compact', 'expanded'], default='compact',
help="Remote Application data format: 'compact' or 'expanded'")
option_parser.add_argument('-a', '--apdu', action='append', required=True, type=is_hexstr, help='C-APDU to send') option_parser.add_argument('-a', '--apdu', action='append', required=True, type=is_hexstr, help='C-APDU to send')
class SmppHandler: class SmppHandler:
@@ -77,7 +80,8 @@ class SmppHandler:
def __init__(self, host: str, port: int, def __init__(self, host: str, port: int,
system_id: str, password: str, system_id: str, password: str,
ota_keyset: OtaKeyset, spi: dict, tar: bytes): ota_keyset: OtaKeyset, spi: dict, tar: bytes,
remote_format: str = 'compact'):
""" """
Initialize connection to SMPP server and set static OTA SMS-TPDU ciphering parameters Initialize connection to SMPP server and set static OTA SMS-TPDU ciphering parameters
Args: Args:
@@ -88,6 +92,7 @@ class SmppHandler:
ota_keyset: OTA keyset to be used for SMS-TPDU ciphering ota_keyset: OTA keyset to be used for SMS-TPDU ciphering
spi: Security Parameter Indicator (SPI) 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 tar: Toolkit Application Reference (TAR) of the targeted card application
remote_format: Remote Application data format ('compact' or 'expanded', TS 102 226)
""" """
# Create and connect SMPP client # Create and connect SMPP client
@@ -103,26 +108,58 @@ class SmppHandler:
self.ota_keyset = ota_keyset self.ota_keyset = ota_keyset
self.tar = tar self.tar = tar
self.spi = spi self.spi = spi
self.remote_format = remote_format
self.reassembler = ConcatenatedSmsReassembler()
def __del__(self): def __del__(self):
if self.client: if self.client:
self.client.unbind() self.client.unbind()
self.client.disconnect() self.client.disconnect()
def _decode_resp(self, tpud: bytes) -> tuple:
"""Decode a response SMS-TPDU into (response_packet, decoded).
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)
"""
try:
return self.ota_dialect.decode_resp(self.ota_keyset, self.spi, tpud,
remote_format=self.remote_format)
except (ValueError, OtaCheckError):
spi = self.spi.copy()
spi['por_shall_be_ciphered'] = False
spi['por_rc_cc_ds'] = 'no_rc_cc_ds'
return self.ota_dialect.decode_resp(self.ota_keyset, spi, tpud,
remote_format=self.remote_format)
def message_received_handler(self, pdu): def message_received_handler(self, pdu):
if pdu.short_message: if not pdu.short_message:
logger.info("SMS-TPDU received: %s", b2h(pdu.short_message)) return None
try: logger.info("SMS-TPDU received: %s", b2h(pdu.short_message))
dec = self.ota_dialect.decode_resp(self.ota_keyset, self.spi, pdu.short_message) tpud = self.reassembler.add(pdu.short_message)
except ValueError: if tpud is None:
# Retry to decoding with ciphering disabled (in case the card has problems to decode the SMS-TDPU logger.info("SMS-TPDU is part of concat message, waiting for more parts...")
# we have sent, the response will contain an unencrypted error message) return None
spi = self.spi.copy() if tpud != pdu.short_message:
spi['por_shall_be_ciphered'] = False logger.info("SMS-TPDU reassembled: %s", b2h(tpud))
spi['por_rc_cc_ds'] = 'no_rc_cc_ds' try:
dec = self.ota_dialect.decode_resp(self.ota_keyset, spi, pdu.short_message) res, decoded = self._decode_resp(tpud)
logger.info("SMS-TPDU decoded: %s", dec) except Exception as e:
self.response = dec # for example ENVELOPE POR
logger.warning("Ignoring undecodable resp SMS-TPDU (%s: %s)", type(e).__name__, e)
return None
logger.info("SMS-TPDU decoded: %s", (res, decoded))
# large app response as reassembled SEND SHORT MESSAGE, but
# the ENVELOPE itself returns a POR without R-APDU.
# smpplib poll() drains all pending SMS in one call, so that PoR is processed
# right after the real response and would overwrite it,
# which leaves transceive_apdu with no last_response_data to return.
# Only allow a response that has no application data (decoded == None)
# if we do not already have a real one.
if decoded is None and self.response is not None and self.response[1] is not None:
logger.info("ignoring status response to keep earlier app response")
return None
self.response = (res, decoded)
return None return None
def message_sent_handler(self, pdu): def message_sent_handler(self, pdu):
@@ -183,10 +220,14 @@ class SmppHandler:
tuple containing the last response data and the last status word as byte strings tuple containing the last response data and the last status word as byte strings
""" """
logger.info("C-APDU sending: %s...", b2h(apdu)) if isinstance(apdu, (list, tuple)):
logger.info("C-APDU(s) sending: %s...", [b2h(a) for a in apdu])
else:
logger.info("C-APDU sending: %s...", b2h(apdu))
# translate to Secured OTA RFM # translate to Secured OTA RFM
secured = self.ota_dialect.encode_cmd(self.ota_keyset, self.tar, self.spi, apdu=apdu) secured = self.ota_dialect.encode_cmd(self.ota_keyset, self.tar, self.spi, apdu=apdu,
remote_format=self.remote_format)
# add user data header # add user data header
tpdu = b'\x02\x70\x00' + secured tpdu = b'\x02\x70\x00' + secured
# send via SMPP # send via SMPP
@@ -200,6 +241,17 @@ class SmppHandler:
container_dict = dict(container) container_dict = dict(container)
resp = container_dict.get('last_response_data') resp = container_dict.get('last_response_data')
sw = container_dict.get('last_status_word') sw = container_dict.get('last_status_word')
# expanded format: decoded response carries
# per command R-APDU list; log each one.
for i, cmd in enumerate(container_dict.get('commands') or []):
logger.info("R-APDU[%u] received: %s %s", i,
cmd['response_data'], cmd['status_word'])
if container_dict.get('truncated'):
logger.warning("Response was TRUNCATED (SW 62F1): the card cut the response "
"data short and did not execute the rest of the script")
if container_dict.get('bad_format') is not None:
logger.warning("Response contains a Bad format TLV: %s",
container_dict['bad_format'])
if resp is None: if resp is None:
raise ValueError("Response does not contain any last_response_data, no R-APDU received!") raise ValueError("Response does not contain any last_response_data, no R-APDU received!")
if sw is None: if sw is None:
@@ -233,8 +285,14 @@ if __name__ == '__main__':
'por_shall_be_ciphered': not opts.por_no_ciphering, 'por_shall_be_ciphered': not opts.por_no_ciphering,
'por_rc_cc_ds': opts.por_rc_cc_ds, 'por_rc_cc_ds': opts.por_rc_cc_ds,
'por': opts.por_req} 'por': opts.por_req}
apdu = h2b("".join(opts.apdu)) if opts.format == 'expanded':
# TS 102 226 5.2.1.1: wrap each apdu in its own C-APDU TLV
apdu = [h2b(a) for a in opts.apdu]
else:
# compact: C-APDUs are concatenated as single command string
apdu = h2b("".join(opts.apdu))
smpp_handler = SmppHandler(opts.host, opts.port, opts.system_id, opts.password, ota_keyset, spi, h2b(opts.tar)) smpp_handler = SmppHandler(opts.host, opts.port, opts.system_id, opts.password, ota_keyset, spi,
h2b(opts.tar), remote_format=opts.format)
resp, sw = smpp_handler.transceive_apdu(apdu, opts.src_addr, opts.dest_addr, opts.timeout) resp, sw = smpp_handler.transceive_apdu(apdu, opts.src_addr, opts.dest_addr, opts.timeout)
print("%s %s" % (b2h(resp), b2h(sw))) print("%s %s" % (b2h(resp), b2h(sw)))
+3 -3
View File
@@ -305,16 +305,16 @@ the requested data.
ADM PIN ADM PIN
~~~~~~~ ^^^^^^^
The `verify_adm` command will attempt to look up the `ADM1` column The `verify_adm` command will attempt to look up the `ADM1` column
indexed by the ICCID of the SIM/UICC. indexed by the ICCID of the SIM/UICC.
SCP02 / SCP03 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 keys. For more details, see the applicable GlobalPlatform
specifications. specifications.
+25 -1
View File
@@ -13,6 +13,7 @@
import os import os
import sys import sys
sys.path.insert(0, os.path.abspath('..')) sys.path.insert(0, os.path.abspath('..'))
sys.path.insert(0, os.path.abspath('.')) # for local extensions (pysim_fs_sphinx, ...)
# -- Project information ----------------------------------------------------- # -- Project information -----------------------------------------------------
@@ -39,7 +40,8 @@ extensions = [
"sphinx.ext.autodoc", "sphinx.ext.autodoc",
"sphinxarg.ext", "sphinxarg.ext",
"sphinx.ext.autosectionlabel", "sphinx.ext.autosectionlabel",
"sphinx.ext.napoleon" "sphinx.ext.napoleon",
"pysim_fs_sphinx",
] ]
# Add any paths that contain templates here, relative to this directory. # Add any paths that contain templates here, relative to this directory.
@@ -64,3 +66,25 @@ html_theme = 'alabaster'
html_static_path = ['_static'] html_static_path = ['_static']
autoclass_content = 'both' 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',
]
+1
View File
@@ -39,6 +39,7 @@ pySim consists of several parts:
:caption: Contents: :caption: Contents:
shell shell
filesystem
trace trace
legacy legacy
smpp2sim smpp2sim
+1 -1
View File
@@ -205,7 +205,7 @@ Specifically, pySim-read will dump the following:
* DF.GSM * DF.GSM
* EF,IMSI * EF.IMSI
* EF.GID1 * EF.GID1
* EF.GID2 * EF.GID2
* EF.SMSP * EF.SMSP
+836
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
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}
+1 -1
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 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 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 Example: Listing applications and their parameters
:: ::
+5 -5
View File
@@ -68,7 +68,7 @@ Usage Examples
suci-tutorial suci-tutorial
cap-tutorial cap-tutorial
put_key-tutorial
Advanced Topics 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, 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. 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 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 us the raw :ref:`update_record` comamdn. encodable; please check your input and/or use the raw :ref:`update_record` command.
decode_hex 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, 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. 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 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 us the raw :ref:`update_binary` comamdn. encodable; please check your input and/or use the raw :ref:`update_binary` command.
decode_hex decode_hex
+29
View File
@@ -170,6 +170,35 @@ 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). .. 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. When the counter has reached its maximum, it will not overflow nor can it be reset.
Expanded remote application data format
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
`smpp-ota-tool` uses the TS 102 226 section 5.1 compact remote application data format by default. This
format concatenates C-APDUs into one command string and only the result of the LAST executed command is reported back.
Retrieving the response data therefore requires a GET RESPONSE C-APDU, and only a single GET RESPONSE command may occur per script.
The TS 102 226 section 5.2 expanded remote application data format removes these limitations: Each C-APDU is
wrapped in its own C-APDU TLV inside a Command Scripting template, and the response is a Response Scripting template that contains one R-APDU TLV with the full response data and status word per executed command. To use it, pass
``--format expanded``; every ``--apdu`` argument then becomes its own C-APDU TLV.
.. note:: The expanded format does not use GET RESPONSE. To retrieve response data from a case 2 or case 4
command, include an ``Le`` field in the C-APDU. i.e. ``Le='00'`` instructs the card to return all available
response data in the R-APDU, with no 256-byte limit (TS 102 226, section 5.2.1.1). Without the ``Le``
field no response data is returned, except a status word for the last command!.
For example, a GP GET STATUS of all applications (``80F24002024F00``) returns a registry that can be much
larger than 256 bytes. In the compact format the card would only answer with ``61xx`` procedure bytes. In the expanded
format, appending ``Le='00'`` (i.e. ``80F24002024F0000``) makes the card return the whole registry in one exchange:
::
$ PYTHONPATH=./ ./contrib/smpp-ota-tool.py --kic <KIC> --kid <KID> --kid-idx 1 --kic-idx 1 \
--algo-crypt triple_des_cbc2 --algo-auth triple_des_cbc2 --tar 000000 --cntr-req no_counter \
--format expanded --apdu 80F24002024F0000
The response data (a concatenation of GlobalPlatform registry TLVs) can then be decoded with
``pySim.global_platform.GpRegistryRelatedData.from_tlv()``.
smpp-ota-tool syntax smpp-ota-tool syntax
~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~
+55 -4
View File
@@ -136,6 +136,52 @@ from pySim.esim.x509_cert import CertAndPrivkey, CertificateSet, cert_get_subjec
import logging # noqa: E402 import logging # noqa: E402
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _disable_twisted_alpn_if_incompatible():
"""Twisted <-> pyOpenSSL TLS compatibility guard applied at import.
Twisted TLSMemoryBIOFactory applies ALPN by setting the 'select' callback
on the SSL Context after it has already created a Connection from that
Context (_createConnection -> _applyProtocolNegotiation).
pyOpenSSL >= 25.0.0 makes a Context immutable once it has been used and
raises, which aborts every inbound TLS handshake, client sees unexpected-EOF
/ decode_error that looks like a cert/cipher problem but is not.
pyOpenSSL < 25 does not import against recent cryptography, so downgrading
it is not a fix.
This server only speaks HTTP/1.1 anyway, so ALPN negotiation is not
needed.
"""
def _major(v):
import re
m = re.match(r'\d+', (v or '').strip())
return int(m.group()) if m else 0
try:
import OpenSSL
except Exception:
return # no pyOpenSSL ???
pyossl_ver = getattr(OpenSSL, '__version__', '0')
if _major(pyossl_ver) < 25:
return # pre-25 pyOpenSSL allows mutating a used Context
try:
import twisted
from twisted.protocols import tls
except Exception:
return
factory = getattr(tls, 'TLSMemoryBIOFactory', None)
if factory is None or not hasattr(factory, '_applyProtocolNegotiation'):
return # Twisted already fixed
factory._applyProtocolNegotiation = lambda self, connection: None
logger.warning("Disabled Twisted ALPN negotiation: Twisted %s + "
"pyOpenSSL %s are incompatible for it",
getattr(twisted, '__version__', '?'), pyossl_ver)
_disable_twisted_alpn_if_incompatible()
# HACK: make this configurable # HACK: make this configurable
DATA_DIR = './smdpp-data' DATA_DIR = './smdpp-data'
HOSTNAME = 'testsmdpplus1.example.com' # must match certificates! HOSTNAME = 'testsmdpplus1.example.com' # must match certificates!
@@ -479,7 +525,7 @@ class SmDppHttpServer:
"""See ES9+ InitiateAuthentication SGP.22 Section 5.6.1""" """See ES9+ InitiateAuthentication SGP.22 Section 5.6.1"""
# Verify that the received address matches its own SM-DP+ address, where the comparison SHALL be # Verify that the received address matches its own SM-DP+ address, where the comparison SHALL be
# case-insensitive. Otherwise, the SM-DP+ SHALL return a status code "SM-DP+ Address - Refused". # case-insensitive. Otherwise, the SM-DP+ SHALL return a status code "SM-DP+ Address - Refused".
if content['smdpAddress'] != self.server_hostname: if content['smdpAddress'].lower() != self.server_hostname.lower():
raise ApiError('8.8.1', '3.8', 'Invalid SM-DP+ Address') raise ApiError('8.8.1', '3.8', 'Invalid SM-DP+ Address')
euiccChallenge = b64decode(content['euiccChallenge']) euiccChallenge = b64decode(content['euiccChallenge'])
@@ -640,7 +686,7 @@ class SmDppHttpServer:
# look up profile based on matchingID. We simply check if a given file exists for now.. # look up profile based on matchingID. We simply check if a given file exists for now..
path = os.path.join(self.upp_dir, matchingId) + '.der' path = os.path.join(self.upp_dir, matchingId) + '.der'
# prevent directory traversal attack # prevent directory traversal attack
if os.path.commonprefix((os.path.realpath(path),self.upp_dir)) != self.upp_dir: if os.path.commonpath((os.path.realpath(path),self.upp_dir)) != self.upp_dir:
raise ApiError('8.2.6', '3.8', 'Refused') raise ApiError('8.2.6', '3.8', 'Refused')
if not os.path.isfile(path) or not os.access(path, os.R_OK): if not os.path.isfile(path) or not os.access(path, os.R_OK):
raise ApiError('8.2.6', '3.8', 'Refused') raise ApiError('8.2.6', '3.8', 'Refused')
@@ -870,12 +916,17 @@ def main(argv):
action='store_true', default=False) action='store_true', default=False)
parser.add_argument("-m", "--in-memory", help="Use ephermal in-memory session storage (for concurrent runs)", parser.add_argument("-m", "--in-memory", help="Use ephermal in-memory session storage (for concurrent runs)",
action='store_true', default=False) action='store_true', default=False)
parser.add_argument("--smdp-address", default=HOSTNAME,
help="ES9+ SM-DP+ address advertised, defaults to \"%(default)s\". "
"Include the TLS port (e.g. %(default)s:8443) when binding a port other "
"than 443, so it matches the address the LPA connects to. "
"The TLS certificate identity is unaffected.")
args = parser.parse_args() args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.WARNING) logging.basicConfig(level=logging.DEBUG if args.verbose else logging.WARNING)
common_cert_path = os.path.join(DATA_DIR, args.certdir) common_cert_path = os.path.join(DATA_DIR, args.certdir)
hs = SmDppHttpServer(server_hostname=HOSTNAME, ci_certs_path=os.path.join(common_cert_path, 'CertificateIssuer'), common_cert_path=common_cert_path, use_brainpool=args.brainpool) hs = SmDppHttpServer(server_hostname=args.smdp_address, ci_certs_path=os.path.join(common_cert_path, 'CertificateIssuer'), common_cert_path=common_cert_path, use_brainpool=args.brainpool)
if(args.nossl): if(args.nossl):
hs.app.run(args.host, args.port) hs.app.run(args.host, args.port)
else: else:
@@ -904,7 +955,7 @@ def main(argv):
with open(cert_pempath, 'wb') as pem_file: with open(cert_pempath, 'wb') as pem_file:
pem_file.write(pem_cert) pem_file.write(pem_cert)
SERVER_STRING = f'ssl:{args.port}:privateKey={cert_skpath}:certKey={cert_pempath}:dhParameters={dhparam_path}' SERVER_STRING = f'ssl:{args.port}:privateKey={cert_skpath}:certKey={cert_pempath}:dhParameters={dhparam_path}:interface={args.host}'
print(SERVER_STRING) print(SERVER_STRING)
hs.app.run(host=HOSTNAME, port=args.port, endpoint_description=SERVER_STRING) hs.app.run(host=HOSTNAME, port=args.port, endpoint_description=SERVER_STRING)
+12 -4
View File
@@ -27,7 +27,6 @@
import hashlib import hashlib
import argparse import argparse
import os import os
import random
import re import re
import sys import sys
import traceback import traceback
@@ -44,6 +43,11 @@ from pySim.legacy.ts_51_011 import EF
from pySim.card_handler import * from pySim.card_handler import *
from pySim.utils 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(): def parse_options():
@@ -185,6 +189,7 @@ def parse_options():
default=False, action="store_true") default=False, action="store_true")
parser.add_argument("--card_handler", dest="card_handler_config", metavar="FILE", parser.add_argument("--card_handler", dest="card_handler_config", metavar="FILE",
help="Use automatic card handling machine") help="Use automatic card handling machine")
parser.add_argument("--verbose", help="Enable verbose logging", action='store_true', default=False)
options = parser.parse_args() options = parser.parse_args()
@@ -430,7 +435,7 @@ def gen_parameters(opts):
if not re.match('^[0-9a-fA-F]{32}$', ki): if not re.match('^[0-9a-fA-F]{32}$', ki):
raise ValueError('Ki needs to be 128 bits, in hex format') raise ValueError('Ki needs to be 128 bits, in hex format')
else: else:
ki = ''.join(['%02x' % random.randrange(0, 256) for i in range(16)]) ki = os.urandom(16).hex()
# OPC (random) # OPC (random)
if opts.opc is not None: if opts.opc is not None:
@@ -441,7 +446,7 @@ def gen_parameters(opts):
elif opts.op is not None: elif opts.op is not None:
opc = derive_milenage_opc(ki, opts.op) opc = derive_milenage_opc(ki, opts.op)
else: else:
opc = ''.join(['%02x' % random.randrange(0, 256) for i in range(16)]) opc = os.urandom(16).hex()
pin_adm = sanitize_pin_adm(opts.pin_adm, opts.pin_adm_hex) pin_adm = sanitize_pin_adm(opts.pin_adm, opts.pin_adm_hex)
@@ -770,6 +775,9 @@ if __name__ == '__main__':
# Parse options # Parse options
opts = parse_options() opts = parse_options()
# Setup logger
PySimLogger.setup(print, {logging.WARN: "\033[33m"}, opts.verbose)
# Init card reader driver # Init card reader driver
sl = init_reader(opts) sl = init_reader(opts)
@@ -808,7 +816,7 @@ if __name__ == '__main__':
print("") print("")
print("Card programming failed with an exception:") print("Card programming failed with an exception:")
print("---------------------8<---------------------") print("---------------------8<---------------------")
traceback.print_exc() print(traceback.format_exc().rstrip())
print("---------------------8<---------------------") print("---------------------8<---------------------")
print("") print("")
rc = -1 rc = -1
+10 -2
View File
@@ -25,7 +25,6 @@
import hashlib import hashlib
import argparse import argparse
import os import os
import random
import re import re
import sys import sys
@@ -46,11 +45,17 @@ from pySim.utils import dec_imsi, dec_iccid
from pySim.legacy.utils import format_xplmn_w_act, dec_st, dec_msisdn from pySim.legacy.utils import format_xplmn_w_act, dec_st, dec_msisdn
from pySim.ts_51_011 import EF_SMSP 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', option_parser = argparse.ArgumentParser(description='Legacy tool for reading some parts of a SIM card',
formatter_class=argparse.ArgumentDefaultsHelpFormatter) formatter_class=argparse.ArgumentDefaultsHelpFormatter)
option_parser.add_argument("--verbose", help="Enable verbose logging", action='store_true', default=False)
argparse_add_reader_args(option_parser) argparse_add_reader_args(option_parser)
def select_app(adf: str, card: SimCard): def select_app(adf: str, card: SimCard):
"""Select application by its AID""" """Select application by its AID"""
sw = 0 sw = 0
@@ -75,6 +80,9 @@ if __name__ == '__main__':
# Parse options # Parse options
opts = option_parser.parse_args() opts = option_parser.parse_args()
# Setup logger
PySimLogger.setup(print, {logging.WARN: "\033[33m"}, opts.verbose)
# Init card reader driver # Init card reader driver
sl = init_reader(opts) sl = init_reader(opts)
+70 -110
View File
@@ -24,21 +24,21 @@ import traceback
import re import re
import cmd2 import cmd2
from packaging import version from packaging import version
from cmd2 import style
import logging import logging
from pySim.log import PySimLogger from pySim.log import PySimLogger
from osmocom.utils import auto_uint8 from osmocom.utils import auto_uint8
# cmd2 >= 2.3.0 has deprecated the bg/fg in favor of Bg/Fg :( # cmd2 >= 3.0 replaced Fg + style() with Color + stylize()
if version.parse(cmd2.__version__) < version.parse("2.3.0"): if version.parse(cmd2.__version__) >= version.parse("3.0.0"):
from cmd2 import fg, bg # pylint: disable=no-name-in-module from cmd2 import Color, stylize # pylint: disable=no-name-in-module
RED = fg.red RED = Color.RED
YELLOW = fg.yellow YELLOW = Color.YELLOW
LIGHT_RED = fg.bright_red LIGHT_RED = Color.BRIGHT_RED
LIGHT_GREEN = fg.bright_green LIGHT_GREEN = Color.BRIGHT_GREEN
def style(text, fg=None, bg=None, bold=False): # pylint: disable=function-redefined
return stylize(text, fg) if fg else text
else: else:
from cmd2 import Fg, Bg # pylint: disable=no-name-in-module from cmd2 import style, Fg # pylint: disable=no-name-in-module
RED = Fg.RED RED = Fg.RED
YELLOW = Fg.YELLOW YELLOW = Fg.YELLOW
LIGHT_RED = Fg.LIGHT_RED LIGHT_RED = Fg.LIGHT_RED
@@ -69,50 +69,26 @@ from pySim.ts_102_222 import Ts102222Commands
from pySim.gsm_r import DF_EIRENE from pySim.gsm_r import DF_EIRENE
from pySim.cat import ProactiveCommand from pySim.cat import ProactiveCommand
from pySim.card_key_provider import CardKeyProviderCsv, CardKeyProviderPgsql from pySim.card_key_provider import card_key_provider_argparse_add_args, card_key_provider_init
from pySim.card_key_provider import card_key_provider_register, card_key_provider_get_field, card_key_provider_get from pySim.card_key_provider import card_key_provider_get_field, card_key_provider_get
from pySim.app import init_card from pySim.app import init_card
log = PySimLogger.get(Path(__file__).stem) log = PySimLogger.get(Path(__file__).stem)
class Cmd2Compat(cmd2.Cmd): class PysimApp(cmd2.Cmd):
"""Backwards-compatibility wrapper around cmd2.Cmd to support older and newer
releases. See https://github.com/python-cmd2/cmd2/blob/master/CHANGELOG.md"""
def run_editor(self, file_path: Optional[str] = None) -> None:
if version.parse(cmd2.__version__) < version.parse("2.0.0"):
return self._run_editor(file_path) # pylint: disable=no-member
else:
return super().run_editor(file_path) # pylint: disable=no-member
class Settable2Compat(cmd2.Settable):
"""Backwards-compatibility wrapper around cmd2.Settable to support older and newer
releases. See https://github.com/python-cmd2/cmd2/blob/master/CHANGELOG.md"""
def __init__(self, name, val_type, description, settable_object, **kwargs):
if version.parse(cmd2.__version__) < version.parse("2.0.0"):
super().__init__(name, val_type, description, **kwargs) # pylint: disable=no-value-for-parameter
else:
super().__init__(name, val_type, description, settable_object, **kwargs) # pylint: disable=too-many-function-args
class PysimApp(Cmd2Compat):
CUSTOM_CATEGORY = 'pySim Commands' CUSTOM_CATEGORY = 'pySim Commands'
BANNER = """Welcome to pySim-shell! BANNER = """Welcome to pySim-shell!
(C) 2021-2023 by Harald Welte, sysmocom - s.f.m.c. GmbH and contributors (C) 2021-2023 by Harald Welte, sysmocom - s.f.m.c. GmbH and contributors
Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/shell.html """ Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/shell.html """
def __init__(self, verbose, card, rs, sl, ch, script=None): def __init__(self, verbose, card, rs, sl, ch, script=None):
if version.parse(cmd2.__version__) < version.parse("2.0.0"):
kwargs = {'use_ipython': True}
else:
kwargs = {'include_ipy': True}
self.verbose = verbose self.verbose = verbose
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}) PySimLogger.setup(self.poutput, {logging.WARN: YELLOW})
self._onchange_verbose('verbose', False, self.verbose)
super().__init__(persistent_history_file='~/.pysim_shell_history', allow_cli_args=False,
auto_load_commands=False, startup_script=script, include_ipy=True)
self.intro = style(self.BANNER, fg=RED) self.intro = style(self.BANNER, fg=RED)
self.default_category = 'pySim-shell built-in commands' self.default_category = 'pySim-shell built-in commands'
self.card = None self.card = None
@@ -125,22 +101,27 @@ Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/
self.numeric_path = False self.numeric_path = False
self.conserve_write = True self.conserve_write = True
self.json_pretty_print = True self.json_pretty_print = True
self.apdu_trace = False self.apdu_trace = getattr(sl, 'apdu_tracer', None) is not None
self.apdu_strict = False self.apdu_strict = False
self.add_settable(Settable2Compat('numeric_path', bool, 'Print File IDs instead of names', self, self.add_settable(cmd2.Settable('numeric_path', bool,
onchange_cb=self._onchange_numeric_path)) 'Print File IDs instead of names',
self.add_settable(Settable2Compat('conserve_write', bool, 'Read and compare before write', self, self, onchange_cb=self._onchange_numeric_path))
onchange_cb=self._onchange_conserve_write)) self.add_settable(cmd2.Settable('conserve_write', bool,
self.add_settable(Settable2Compat('json_pretty_print', bool, 'Pretty-Print JSON output', self)) 'Read and compare before write',
self.add_settable(Settable2Compat('apdu_trace', bool, 'Trace and display APDUs exchanged with card', self, self, onchange_cb=self._onchange_conserve_write))
onchange_cb=self._onchange_apdu_trace)) self.add_settable(cmd2.Settable('json_pretty_print', bool,
self.add_settable(Settable2Compat('apdu_strict', bool, 'Pretty-Print JSON output',
'Enforce APDU responses according to ISO/IEC 7816-3, table 12', self, self))
onchange_cb=self._onchange_apdu_strict)) self.add_settable(cmd2.Settable('apdu_trace', bool,
self.add_settable(Settable2Compat('verbose', bool, 'Trace and display APDUs exchanged with card',
'Enable/disable verbose logging', self, self, onchange_cb=self._onchange_apdu_trace))
onchange_cb=self._onchange_verbose)) self.add_settable(cmd2.Settable('apdu_strict', bool,
'Strictly apply APDU format according to ISO/IEC 7816-3, table 12',
self))
self.add_settable(cmd2.Settable('verbose', bool,
'Enable/disable verbose logging',
self, onchange_cb=self._onchange_verbose))
self.equip(card, rs) self.equip(card, rs)
def equip(self, card, rs): def equip(self, card, rs):
@@ -218,13 +199,6 @@ Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/
else: else:
self.card._scc._tp.apdu_tracer = None 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): def _onchange_verbose(self, param_name, old, new):
PySimLogger.set_verbose(new) PySimLogger.set_verbose(new)
if new == True: if new == True:
@@ -236,8 +210,10 @@ Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/
def __init__(self, cmd2_app): def __init__(self, cmd2_app):
self.cmd2 = cmd2_app self.cmd2 = cmd2_app
def trace_response(self, cmd, sw, resp): def trace_command(self, cmd):
self.cmd2.poutput("-> %s %s" % (cmd[:10], cmd[10:])) self.cmd2.poutput("-> %s %s" % (cmd[:10], cmd[10:]))
def trace_response(self, cmd, sw, resp):
self.cmd2.poutput("<- %s: %s" % (sw, resp)) self.cmd2.poutput("<- %s: %s" % (sw, resp))
def update_prompt(self): def update_prompt(self):
@@ -281,7 +257,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-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('--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('--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) @cmd2.with_argparser(apdu_cmd_parser)
def do_apdu(self, opts): def do_apdu(self, opts):
@@ -290,14 +266,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 tracked. Depending on the raw APDU sent, pySim-shell may not continue to work as expected if you e.g. select
a different file.""" 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 # 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 # 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 # 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.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: if opts.raw or self.lchan is None:
data, sw = self.card._scc.send_apdu(opts.APDU, apply_lchan = False) data, sw = self.card._scc.send_apdu(opts.APDU, apply_lchan = False)
else: else:
data, sw = self.lchan.scc.send_apdu(opts.APDU, apply_lchan = False) data, sw = self.lchan.scc.send_apdu(opts.APDU, apply_lchan = False)
self.card._scc._tp.apdu_strict = True
if data: if data:
self.poutput("SW: %s, RESP: %s" % (sw, data)) self.poutput("SW: %s, RESP: %s" % (sw, data))
else: else:
@@ -366,7 +351,7 @@ Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/
self.poutput("") self.poutput("")
self.poutput("Card initialization (%s) failed with an exception:" % str(self.sl)) self.poutput("Card initialization (%s) failed with an exception:" % str(self.sl))
self.poutput("---------------------8<---------------------") self.poutput("---------------------8<---------------------")
traceback.print_exc() self.poutput(traceback.format_exc().rstrip())
self.poutput("---------------------8<---------------------") self.poutput("---------------------8<---------------------")
self.poutput("") self.poutput("")
return -1 return -1
@@ -480,7 +465,7 @@ Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/
self.poutput("") self.poutput("")
self.poutput("Card handling (%s) failed with an exception:" % str(self.sl)) self.poutput("Card handling (%s) failed with an exception:" % str(self.sl))
self.poutput("---------------------8<---------------------") self.poutput("---------------------8<---------------------")
traceback.print_exc() self.poutput(traceback.format_exc().rstrip())
self.poutput("---------------------8<---------------------") self.poutput("---------------------8<---------------------")
self.poutput("") self.poutput("")
fail_count = fail_count + 1 fail_count = fail_count + 1
@@ -1145,23 +1130,14 @@ global_group.add_argument("--skip-card-init", help="Skip all card/profile initia
global_group.add_argument("--verbose", help="Enable verbose logging", global_group.add_argument("--verbose", help="Enable verbose logging",
action='store_true', default=False) action='store_true', default=False)
card_key_group = option_parser.add_argument_group('Card Key Provider Options')
card_key_group.add_argument('--csv', metavar='FILE',
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',
help='per-column AES transport key', dest='column_key')
adm_group = global_group.add_mutually_exclusive_group() adm_group = global_group.add_mutually_exclusive_group()
adm_group.add_argument('-a', '--pin-adm', metavar='PIN_ADM1', dest='pin_adm', default=None, adm_group.add_argument('-a', '--pin-adm', metavar='PIN_ADM', dest='pin_adm', default=None,
help='ADM PIN used for provisioning (overwrites default)') help='ADM PIN used for provisioning (overwrites default)')
adm_group.add_argument('-A', '--pin-adm-hex', metavar='PIN_ADM1_HEX', dest='pin_adm_hex', default=None, adm_group.add_argument('-A', '--pin-adm-hex', metavar='PIN_ADM_HEX', dest='pin_adm_hex', default=None,
help='ADM PIN used for provisioning, as hex string (16 characters long)') help='ADM PIN used for provisioning, as hex string (16 characters long)')
global_group.add_argument('--pin-adm-type',
choices=[x for x in pin_names.values() if x.startswith('ADM')],
help='Override ADM number. Default is card-model-specific, usually 1')
option_parser.add_argument('-e', '--execute-command', action='append', default=[], option_parser.add_argument('-e', '--execute-command', action='append', default=[],
help='A pySim-shell command that will be executed at startup') help='A pySim-shell command that will be executed at startup')
@@ -1169,30 +1145,17 @@ option_parser.add_argument("command", nargs='?',
help="A pySim-shell command that would optionally be executed at startup") help="A pySim-shell command that would optionally be executed at startup")
option_parser.add_argument('command_args', nargs=argparse.REMAINDER, option_parser.add_argument('command_args', nargs=argparse.REMAINDER,
help="Optional Arguments for command") help="Optional Arguments for command")
card_key_provider_argparse_add_args(option_parser)
if __name__ == '__main__': if __name__ == '__main__':
startup_errors = False startup_errors = False
opts = option_parser.parse_args() opts = option_parser.parse_args()
# Ensure that we are able to print formatted warnings from the beginning. # Ensure that we are able to print formatted warnings from the beginning.
PySimLogger.setup(print, {logging.WARN: YELLOW}) PySimLogger.setup(print, {logging.WARN: YELLOW}, opts.verbose)
if opts.verbose:
PySimLogger.set_verbose(True)
PySimLogger.set_level(logging.DEBUG)
else:
PySimLogger.set_verbose(False)
PySimLogger.set_level(logging.INFO)
# Register csv-file as card data provider, either from specified CSV # Init card key provider for automatic card key retrieval
# or from CSV file in home directory card_key_provider_init(opts)
column_keys = {}
for par in opts.column_key:
name, key = par.split(':')
column_keys[name] = key
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 # Init card reader driver
sl = init_reader(opts, proactive_handler = Proact()) sl = init_reader(opts, proactive_handler = Proact())
@@ -1213,7 +1176,7 @@ if __name__ == '__main__':
startup_errors = True startup_errors = True
print("Card initialization (%s) failed with an exception:" % str(sl)) print("Card initialization (%s) failed with an exception:" % str(sl))
print("---------------------8<---------------------") print("---------------------8<---------------------")
traceback.print_exc() print(traceback.format_exc().rstrip())
print("---------------------8<---------------------") print("---------------------8<---------------------")
if not opts.noprompt: if not opts.noprompt:
print("(you may still try to recover from this manually by using the 'equip' command.)") print("(you may still try to recover from this manually by using the 'equip' command.)")
@@ -1224,18 +1187,15 @@ if __name__ == '__main__':
# If the user supplies an ADM PIN at via commandline args authenticate # If the user supplies an ADM PIN at via commandline args authenticate
# immediately so that the user does not have to use the shell commands # immediately so that the user does not have to use the shell commands
pin_adm = sanitize_pin_adm(opts.pin_adm, opts.pin_adm_hex) pin_adm_type = ""
if pin_adm: if opts.pin_adm_type:
if not card: pin_adm_type = "--adm-type %s" % opts.pin_adm_type
print("Card error, cannot do ADM verification with supplied ADM pin now.") if opts.pin_adm:
try: app.onecmd_plus_hooks("verify_adm %s %s" %
card._scc.verify_chv(card._adm_chv_num, h2b(pin_adm)) (opts.pin_adm, pin_adm_type), add_to_history = False)
except Exception as e: elif opts.pin_adm_hex:
startup_errors = True app.onecmd_plus_hooks("verify_adm %s --pin-is-hex %s" %
print("ADM verification (%s) failed with an exception:" % str(pin_adm)) (opts.pin_adm_hex, pin_adm_type), add_to_history = False)
print("---------------------8<---------------------")
print(e)
print("---------------------8<---------------------")
# Run optional commands # Run optional commands
for c in opts.execute_command: for c in opts.execute_command:
+33 -227
View File
@@ -30,10 +30,13 @@
import argparse import argparse
import logging import logging
import socket
import threading
import time
import colorlog import colorlog
from twisted.protocols import basic from twisted.protocols import basic
from twisted.internet import defer, endpoints, protocol, reactor, task from twisted.internet import defer, endpoints, reactor, task
from twisted.cred.portal import IRealm from twisted.cred.portal import IRealm
from twisted.cred.checkers import InMemoryUsernamePasswordDatabaseDontUse from twisted.cred.checkers import InMemoryUsernamePasswordDatabaseDontUse
from twisted.cred.portal import Portal from twisted.cred.portal import Portal
@@ -47,13 +50,16 @@ from smpp.pdu import pdu_types, operations, pdu_encoding
from pySim.sms import SMS_DELIVER, SMS_SUBMIT, AddressField from pySim.sms import SMS_DELIVER, SMS_SUBMIT, AddressField
from pySim.bip import Proact, terminal_profile
from pySim.transport import LinkBase, ProactiveHandler, argparse_add_reader_args, init_reader, ApduTracer from pySim.transport import LinkBase, ProactiveHandler, argparse_add_reader_args, init_reader, ApduTracer
from pySim.commands import SimCardCommands from pySim.commands import SimCardCommands
from pySim.cards import UiccCardBase from pySim.cards import UiccCardBase
from pySim.exceptions import * from pySim.exceptions import *
from pySim.cat import sms_pp_download_envelope
from pySim.cat import ProactiveCommand, SendShortMessage, SMS_TPDU, SMSPPDownload, BearerDescription from pySim.cat import ProactiveCommand, SendShortMessage, SMS_TPDU, SMSPPDownload, BearerDescription
from pySim.cat import DeviceIdentities, Address, OtherAddress, UiccTransportLevel, BufferSize from pySim.cat import DeviceIdentities, Address, OtherAddress, UiccTransportLevel, BufferSize
from pySim.cat import ChannelStatus, ChannelData, ChannelDataLength from pySim.cat import ChannelStatus, ChannelData, ChannelDataLength
from pySim.cat import EventList, EventDownload, Result
from pySim.utils import b2h, h2b from pySim.utils import b2h, h2b
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -71,224 +77,6 @@ class MyApduTracer(ApduTracer):
print("-> %s %s" % (cmd[:10], cmd[10:])) print("-> %s %s" % (cmd[:10], cmd[10:]))
print("<- %s: %s" % (sw, resp)) print("<- %s: %s" % (sw, resp))
class TcpProtocol(protocol.Protocol):
def dataReceived(self, data):
pass
def connectionLost(self, reason):
pass
def tcp_connected_callback(p: protocol.Protocol):
"""called by twisted TCP client."""
logger.error("%s: connected!" % p)
class ProactChannel:
"""Representation of a single protective channel."""
def __init__(self, channels: 'ProactChannels', chan_nr: int):
self.channels = channels
self.chan_nr = chan_nr
self.ep = None
def close(self):
"""Close the channel."""
if self.ep:
self.ep.disconnect()
self.channels.channel_delete(self.chan_nr)
class ProactChannels:
"""Wrapper class for maintaining state of proactive channels."""
def __init__(self):
self.channels = {}
def channel_create(self) -> ProactChannel:
"""Create a new proactive channel, allocating its integer number."""
for i in range(1, 9):
if not i in self.channels:
self.channels[i] = ProactChannel(self, i)
return self.channels[i]
raise ValueError('Cannot allocate another channel: All channels active')
def channel_delete(self, chan_nr: int):
del self.channels[chan_nr]
class Proact(ProactiveHandler):
#def __init__(self, smpp_factory):
# self.smpp_factory = smpp_factory
def __init__(self):
self.channels = ProactChannels()
@staticmethod
def _find_first_element_of_type(instlist, cls):
for i in instlist:
if isinstance(i, cls):
return i
return None
"""Call-back which the pySim transport core calls whenever it receives a
proactive command from the SIM."""
def handle_SendShortMessage(self, pcmd: ProactiveCommand):
# {'smspp_download': [{'device_identities': {'source_dev_id': 'network',
# 'dest_dev_id': 'uicc'}},
# {'address': {'ton_npi': {'ext': True,
# 'type_of_number': 'international',
# 'numbering_plan_id': 'isdn_e164'},
# 'call_number': '79'}},
# {'sms_tpdu': {'tpdu': '40048111227ff6407070611535004d02700000481516011212000001fe4c0943aea42e45021c078ae06c66afc09303608874b72f58bacadb0dcf665c29349c799fbb522e61709c9baf1890015e8e8e196e36153106c8b92f95153774'}}
# ]}
"""Card requests sending a SMS. We need to pass it on to the ESME via SMPP."""
logger.info("SendShortMessage")
logger.info(pcmd)
# Relevant parts in pcmd: Address, SMS_TPDU
addr_ie = Proact._find_first_element_of_type(pcmd.children, Address)
sms_tpdu_ie = Proact._find_first_element_of_type(pcmd.children, SMS_TPDU)
raw_tpdu = sms_tpdu_ie.decoded['tpdu']
submit = SMS_SUBMIT.from_bytes(raw_tpdu)
submit.tp_da = AddressField(addr_ie.decoded['call_number'], addr_ie.decoded['ton_npi']['type_of_number'],
addr_ie.decoded['ton_npi']['numbering_plan_id'])
logger.info(submit)
self.send_sms_via_smpp(submit)
def handle_OpenChannel(self, pcmd: ProactiveCommand):
"""Card requests opening a new channel via a UDP/TCP socket."""
# {'open_channel': [{'command_details': {'command_number': 1,
# 'type_of_command': 'open_channel',
# 'command_qualifier': 3}},
# {'device_identities': {'source_dev_id': 'uicc',
# 'dest_dev_id': 'terminal'}},
# {'bearer_description': {'bearer_type': 'default',
# 'bearer_parameters': ''}},
# {'buffer_size': 1024},
# {'uicc_transport_level': {'protocol_type': 'tcp_uicc_client_remote',
# 'port_number': 32768}},
# {'other_address': {'type_of_address': 'ipv4',
# 'address': '01020304'}}
# ]}
logger.info("OpenChannel")
logger.info(pcmd)
transp_lvl_ie = Proact._find_first_element_of_type(pcmd.children, UiccTransportLevel)
other_addr_ie = Proact._find_first_element_of_type(pcmd.children, OtherAddress)
bearer_desc_ie = Proact._find_first_element_of_type(pcmd.children, BearerDescription)
buffer_size_ie = Proact._find_first_element_of_type(pcmd.children, BufferSize)
if transp_lvl_ie.decoded['protocol_type'] != 'tcp_uicc_client_remote':
raise ValueError('Unsupported protocol_type')
if other_addr_ie.decoded.get('type_of_address', None) != 'ipv4':
raise ValueError('Unsupported type_of_address')
ipv4_bytes = h2b(other_addr_ie.decoded['address'])
ipv4_str = '%u.%u.%u.%u' % (ipv4_bytes[0], ipv4_bytes[1], ipv4_bytes[2], ipv4_bytes[3])
port_nr = transp_lvl_ie.decoded['port_number']
print("%s:%u" % (ipv4_str, port_nr))
channel = self.channels.channel_create()
channel.ep = endpoints.TCP4ClientEndpoint(reactor, ipv4_str, port_nr)
channel.prot = TcpProtocol()
d = endpoints.connectProtocol(channel.ep, channel.prot)
# FIXME: why is this never called despite the client showing the inbound connection?
d.addCallback(tcp_connected_callback)
# Terminal Response example: [
# {'command_details': {'command_number': 1,
# 'type_of_command': 'open_channel',
# 'command_qualifier': 3}},
# {'device_identities': {'source_dev_id': 'terminal', 'dest_dev_id': 'uicc'}},
# {'result': {'general_result': 'performed_successfully', 'additional_information': ''}},
# {'channel_status': '8100'},
# {'bearer_description': {'bearer_type': 'default', 'bearer_parameters': ''}},
# {'buffer_size': 1024}
# ]
return self.prepare_response(pcmd) + [ChannelStatus(decoded='8100'), bearer_desc_ie, buffer_size_ie]
def handle_CloseChannel(self, pcmd: ProactiveCommand):
"""Close a channel."""
logger.info("CloseChannel")
logger.info(pcmd)
def handle_ReceiveData(self, pcmd: ProactiveCommand):
"""Receive/read data from the socket."""
# {'receive_data': [{'command_details': {'command_number': 1,
# 'type_of_command': 'receive_data',
# 'command_qualifier': 0}},
# {'device_identities': {'source_dev_id': 'uicc',
# 'dest_dev_id': 'channel_1'}},
# {'channel_data_length': 9}
# ]}
logger.info("ReceiveData")
logger.info(pcmd)
# Terminal Response example: [
# {'command_details': {'command_number': 1,
# 'type_of_command': 'receive_data',
# 'command_qualifier': 0}},
# {'device_identities': {'source_dev_id': 'terminal', 'dest_dev_id': 'uicc'}},
# {'result': {'general_result': 'performed_successfully', 'additional_information': ''}},
# {'channel_data': '16030100040e000000'},
# {'channel_data_length': 0}
# ]
return self.prepare_response(pcmd) + []
def handle_SendData(self, pcmd: ProactiveCommand):
"""Send/write data received from the SIM to the socket."""
# {'send_data': [{'command_details': {'command_number': 1,
# 'type_of_command': 'send_data',
# 'command_qualifier': 1}},
# {'device_identities': {'source_dev_id': 'uicc',
# 'dest_dev_id': 'channel_1'}},
# {'channel_data': '160301003c010000380303d0f45e12b52ce5bb522750dd037738195334c87a46a847fe2b6886cada9ea6bf00000a00ae008c008b00b0002c010000050001000101'}
# ]}
logger.info("SendData")
logger.info(pcmd)
dev_id_ie = Proact._find_first_element_of_type(pcmd.children, DeviceIdentities)
chan_data_ie = Proact._find_first_element_of_type(pcmd.children, ChannelData)
chan_str = dev_id_ie.decoded['dest_dev_id']
chan_nr = 1 # FIXME
chan = self.channels.channels.get(chan_nr, None)
# FIXME chan.prot.transport.write(h2b(chan_data_ie.decoded))
# Terminal Response example: [
# {'command_details': {'command_number': 1,
# 'type_of_command': 'send_data',
# 'command_qualifier': 1}},
# {'device_identities': {'source_dev_id': 'terminal', 'dest_dev_id': 'uicc'}},
# {'result': {'general_result': 'performed_successfully', 'additional_information': ''}},
# {'channel_data_length': 255}
# ]
return self.prepare_response(pcmd) + [ChannelDataLength(decoded=255)]
def handle_SetUpEventList(self, pcmd: ProactiveCommand):
# {'set_up_event_list': [{'command_details': {'command_number': 1,
# 'type_of_command': 'set_up_event_list',
# 'command_qualifier': 0}},
# {'device_identities': {'source_dev_id': 'uicc',
# 'dest_dev_id': 'terminal'}},
# {'event_list': ['data_available', 'channel_status']}
# ]}
logger.info("SetUpEventList")
logger.info(pcmd)
# Terminal Response example: [
# {'command_details': {'command_number': 1,
# 'type_of_command': 'set_up_event_list',
# 'command_qualifier': 0}},
# {'device_identities': {'source_dev_id': 'terminal', 'dest_dev_id': 'uicc'}},
# {'result': {'general_result': 'performed_successfully', 'additional_information': ''}}
# ]
return self.prepare_response(pcmd)
def getChannelStatus(self, pcmd: ProactiveCommand):
logger.info("GetChannelStatus")
logger.info(pcmd)
return self.prepare_response(pcmd) + []
def send_sms_via_smpp(self, submit: SMS_SUBMIT):
# while in a normal network the phone/ME would *submit* a message to the SMSC,
# we are actually emulating the SMSC itself, so we must *deliver* the message
# to the ESME
deliver = SMS_DELIVER.from_submit(submit)
deliver_smpp = deliver.to_smpp()
hackish_global_smpp.sendDataRequest(deliver_smpp)
# # obtain the connection/binding of system_id to be used for delivering MO-SMS to the ESME
# connection = smpp_server.getBoundConnections[system_id].getNextBindingForDelivery()
# connection.sendDataRequest(deliver_smpp)
def dcs_is_8bit(dcs): def dcs_is_8bit(dcs):
if dcs == pdu_types.DataCoding(pdu_types.DataCodingScheme.DEFAULT, if dcs == pdu_types.DataCoding(pdu_types.DataCodingScheme.DEFAULT,
pdu_types.DataCodingDefault.OCTET_UNSPECIFIED): pdu_types.DataCodingDefault.OCTET_UNSPECIFIED):
@@ -323,6 +111,11 @@ class MyServer:
smppEndpoint = endpoints.TCP6ServerEndpoint(reactor, tcp_port, interface=bind_ip) smppEndpoint = endpoints.TCP6ServerEndpoint(reactor, tcp_port, interface=bind_ip)
smppEndpoint.listen(self.factory) smppEndpoint.listen(self.factory)
self.tp = self.scc = self.card = None self.tp = self.scc = self.card = None
# Serialise card/APDU access.
# - SMPP handler drives the card from reactor thread
# - BIP relay data-available path drives it from socket reader thread.
# The transport is not re-entrant, both must take this lock.
self._card_lock = threading.Lock()
def connect_to_card(self, tp: LinkBase): def connect_to_card(self, tp: LinkBase):
self.tp = tp self.tp = tp
@@ -333,8 +126,22 @@ class MyServer:
self.scc.sel_ctrl = "0004" self.scc.sel_ctrl = "0004"
self.card.read_aids() self.card.read_aids()
self.card.select_adf_by_aid(adf='usim') self.card.select_adf_by_aid(adf='usim')
# FIXME: create a more realistic profile than ffffff self.scc.terminal_profile(b2h(terminal_profile()))
self.scc.terminal_profile('ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff') # Connect the BIP relay inbound path to the card.
# relay socket receives data -> ME initiated ENVELOPE EVENT DOWNLOA
# -> triggers RECEIVE DATA proactive session.
# FIXME this cross-thread push to the card is exercised only with real hardware
# the card free tests cover socket relay + envelope construction, not delivery.
handler = getattr(tp, 'proactive_handler', None)
if isinstance(handler, Proact):
handler.data_available_sink = self._deliver_data_available
def _deliver_data_available(self, envelope_hex: str):
"""push ME initiated ENVELOPE EVENT DOWNLOAD to the card"""
with self._card_lock:
logger.info("ENVELOPE(Data available): %s" % envelope_hex)
(data, sw) = self.scc.envelope(envelope_hex)
logger.info("SW %s: %s" % (sw, data))
def _msgHandler(self, system_id, smpp, pdu): def _msgHandler(self, system_id, smpp, pdu):
"""Handler for incoming messages received via SMPP from ESME.""" """Handler for incoming messages received via SMPP from ESME."""
@@ -362,14 +169,12 @@ class MyServer:
tpdu = SMS_DELIVER.from_smpp_submit(pdu) tpdu = SMS_DELIVER.from_smpp_submit(pdu)
logger.info(tpdu) logger.info(tpdu)
# 2) wrap into the CAT ENVELOPE for SMS-PP-Download # 2) wrap into the CAT ENVELOPE for SMS-PP-Download
tpdu_ie = SMS_TPDU(decoded={'tpdu': b2h(tpdu.to_bytes())}) sms_dl = sms_pp_download_envelope(tpdu)
addr_ie = Address(decoded={'ton_npi': {'ext':False, 'type_of_number':'unknown', 'numbering_plan_id':'unknown'}, 'call_number': '0123456'})
dev_ids = DeviceIdentities(decoded={'source_dev_id': 'network', 'dest_dev_id': 'uicc'})
sms_dl = SMSPPDownload(children=[dev_ids, addr_ie, tpdu_ie])
# 3) send to the card # 3) send to the card
envelope_hex = b2h(sms_dl.to_tlv()) envelope_hex = b2h(sms_dl.to_tlv())
logger.info("ENVELOPE: %s" % envelope_hex) logger.info("ENVELOPE: %s" % envelope_hex)
(data, sw) = self.scc.envelope(envelope_hex) with self._card_lock:
(data, sw) = self.scc.envelope(envelope_hex)
logger.info("SW %s: %s" % (sw, data)) logger.info("SW %s: %s" % (sw, data))
if sw in ['9200', '9300']: if sw in ['9200', '9300']:
# TODO send back RP-ERROR message with TP-FCS == 'SIM Application Toolkit Busy' # TODO send back RP-ERROR message with TP-FCS == 'SIM Application Toolkit Busy'
@@ -416,7 +221,8 @@ if __name__ == '__main__':
opts = option_parser.parse_args() opts = option_parser.parse_args()
tp = init_reader(opts, proactive_handler = Proact()) tp = init_reader(opts, proactive_handler = Proact(
sms_sink=lambda pdu: hackish_global_smpp.sendDataRequest(pdu)))
if tp is None: if tp is None:
exit(1) exit(1)
tp.connect() tp.connect()
+1 -1
View File
@@ -117,7 +117,7 @@ class Tracer:
try: try:
apdu = self.source.read() apdu = self.source.read()
apdu_counter = apdu_counter + 1 apdu_counter = apdu_counter + 1
except StopIteration: except (StopIteration, KeyboardInterrupt):
print("%i APDUs parsed, stop iteration." % apdu_counter) print("%i APDUs parsed, stop iteration." % apdu_counter)
return 0 return 0
+11 -7
View File
@@ -26,11 +26,15 @@ from pySim.cdma_ruim import CardProfileRUIM
from pySim.ts_102_221 import CardProfileUICC from pySim.ts_102_221 import CardProfileUICC
from pySim.utils import all_subclasses from pySim.utils import all_subclasses
from pySim.exceptions import SwMatchError from pySim.exceptions import SwMatchError
from pySim.log import PySimLogger
# we need to import this module so that the SysmocomSJA2 sub-class of log = PySimLogger.get(__name__)
# CardModel is created, which will add the ATR-based matching and
# calling of SysmocomSJA2.add_files. See CardModel.apply_matching_models # we need to import these modules so that the SysmocomSJA2 / SysmocomSJS1
# sub-classes of CardModel are created, which will add the ATR-based matching
# and calling of their add_files. See CardModel.apply_matching_models
import pySim.sysmocom_sja2 import pySim.sysmocom_sja2
import pySim.sysmocom_sjs1
# we need to import these modules so that the various sub-classes of # we need to import these modules so that the various sub-classes of
# CardProfile are created, which will be used in init_card() to iterate # CardProfile are created, which will be used in init_card() to iterate
@@ -54,7 +58,7 @@ def init_card(sl: LinkBase, skip_card_init: bool = False) -> Tuple[RuntimeState,
# Wait up to three seconds for a card in reader and try to detect # Wait up to three seconds for a card in reader and try to detect
# the card type. # the card type.
print("Waiting for card...") log.info("Waiting for card...")
sl.wait_for_card(3) sl.wait_for_card(3)
# The user may opt to skip all card initialization. In this case only the # The user may opt to skip all card initialization. In this case only the
@@ -66,7 +70,7 @@ def init_card(sl: LinkBase, skip_card_init: bool = False) -> Tuple[RuntimeState,
generic_card = False generic_card = False
card = card_detect(scc) card = card_detect(scc)
if card is None: if card is None:
print("Warning: Could not detect card type - assuming a generic card type...") log.warning("Could not detect card type - assuming a generic card type...")
card = SimCardBase(scc) card = SimCardBase(scc)
generic_card = True generic_card = True
@@ -76,7 +80,7 @@ def init_card(sl: LinkBase, skip_card_init: bool = False) -> Tuple[RuntimeState,
# just means that pySim was unable to recognize the card profile. This # just means that pySim was unable to recognize the card profile. This
# may happen in particular with unprovisioned cards that do not have # may happen in particular with unprovisioned cards that do not have
# any files on them yet. # any files on them yet.
print("Unsupported card type!") log.warning("Unsupported card type!")
return None, card return None, card
# ETSI TS 102 221, Table 9.3 specifies a default for the PIN key # ETSI TS 102 221, Table 9.3 specifies a default for the PIN key
@@ -87,7 +91,7 @@ def init_card(sl: LinkBase, skip_card_init: bool = False) -> Tuple[RuntimeState,
if generic_card and isinstance(profile, CardProfileUICC): if generic_card and isinstance(profile, CardProfileUICC):
card._adm_chv_num = 0x0A card._adm_chv_num = 0x0A
print("Info: Card is of type: %s" % str(profile)) log.info("Card is of type: %s", str(profile))
# FIXME: this shouldn't really be here but somewhere else/more generic. # FIXME: this shouldn't really be here but somewhere else/more generic.
# We cannot do it within pySim/profile.py as that would create circular # We cannot do it within pySim/profile.py as that would create circular
+58 -47
View File
@@ -72,10 +72,10 @@ class ApduArDO(BER_TLV_IE, tag=0xd0):
if do[0] == 0x01: if do[0] == 0x01:
self.decoded = {'generic_access_rule': 'always'} self.decoded = {'generic_access_rule': 'always'}
return self.decoded return self.decoded
return ValueError('Invalid 1-byte generic APDU access rule') raise ValueError('Invalid 1-byte generic APDU access rule')
else: else:
if len(do) % 8: 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': []} self.decoded = {'apdu_filter': []}
offset = 0 offset = 0
while offset < len(do): while offset < len(do):
@@ -90,19 +90,19 @@ class ApduArDO(BER_TLV_IE, tag=0xd0):
return b'\x00' return b'\x00'
if self.decoded['generic_access_rule'] == 'always': if self.decoded['generic_access_rule'] == 'always':
return b'\x01' return b'\x01'
return ValueError('Invalid 1-byte generic APDU access rule') raise ValueError('Invalid 1-byte generic APDU access rule')
else: else:
if not 'apdu_filter' in self.decoded: if not 'apdu_filter' in self.decoded:
return ValueError('Invalid APDU AR DO') raise ValueError('Invalid APDU AR DO')
filters = self.decoded['apdu_filter'] filters = self.decoded['apdu_filter']
res = b'' res = b''
for f in filters: for f in filters:
if not 'header' in f or not 'mask' in f: 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']) header_b = h2b(f['header'])
mask_b = h2b(f['mask']) mask_b = h2b(f['mask'])
if len(header_b) != 4 or len(mask_b) != 4: 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 res += header_b + mask_b
return res return res
@@ -269,7 +269,7 @@ class ADF_ARAM(CardADF):
cmd_do_enc = cmd_do.to_ie() cmd_do_enc = cmd_do.to_ie()
cmd_do_len = len(cmd_do_enc) cmd_do_len = len(cmd_do_enc)
if cmd_do_len > 255: if cmd_do_len > 255:
return ValueError('DO > 255 bytes not supported yet') raise ValueError('DO > 255 bytes not supported yet')
else: else:
cmd_do_enc = b'' cmd_do_enc = b''
cmd_do_len = 0 cmd_do_len = 0
@@ -300,6 +300,51 @@ class ADF_ARAM(CardADF):
'major': v_major, 'minor': v_minor, 'patch': v_patch}}]) 'major': v_major, 'minor': v_minor, 'patch': v_patch}}])
return ADF_ARAM.xceive_apdu_tlv(scc, '80cadf21', cmd_do, ResponseAramConfigDO) return ADF_ARAM.xceive_apdu_tlv(scc, '80cadf21', cmd_do, ResponseAramConfigDO)
@staticmethod
def store_ref_ar_do(scc, aid:Hexstr, aid_empty:bool, device_app_id:Hexstr, pkg_ref:str,
apdu_filter:Hexstr, apdu_never:bool, apdu_always:bool,
nfc_always:bool, nfc_never:bool, android_permissions:Hexstr):
# REF
ref_do_content = []
if aid is not None:
ref_do_content += [{'aid_ref_do': aid}]
elif aid_empty:
ref_do_content += [{'aid_ref_empty_do': None}]
ref_do_content += [{'dev_app_id_ref_do': device_app_id}]
if pkg_ref:
ref_do_content += [{'pkg_ref_do': {'package_name_string': pkg_ref}}]
# AR
ar_do_content = []
if apdu_never:
ar_do_content += [{'apdu_ar_do': {'generic_access_rule': 'never'}}]
elif apdu_always:
ar_do_content += [{'apdu_ar_do': {'generic_access_rule': 'always'}}]
elif apdu_filter:
if len(apdu_filter) % 16:
raise ValueError(f'Invalid non-modulo-16 length of APDU filter: {len(apdu_filter)}')
offset = 0
apdu_filter_list = []
while offset < len(apdu_filter):
apdu_filter_list += [{'header': apdu_filter[offset:offset+8],
'mask': apdu_filter[offset+8:offset+16]}]
offset += 16 # Move offset to the beginning of the next apdu_filter object
ar_do_content += [{'apdu_ar_do': {'apdu_filter': apdu_filter_list}}]
if nfc_never:
ar_do_content += [{'nfc_ar_do': {'nfc_event_access_rule': 'never'}}]
elif nfc_always:
ar_do_content += [{'nfc_ar_do': {'nfc_event_access_rule': 'always'}}]
if android_permissions:
ar_do_content += [{'perm_ar_do': {'permissions': android_permissions}}]
d = [{'ref_ar_do': [{'ref_do': ref_do_content}, {'ar_do': ar_do_content}]}]
csrado = CommandStoreRefArDO()
csrado.from_val_dict(d)
return ADF_ARAM.store_data(scc, csrado)
@staticmethod
def aram_delete_all(scc):
deldo = CommandDelete()
return ADF_ARAM.store_data(scc, deldo)
@with_default_category('Application-Specific Commands') @with_default_category('Application-Specific Commands')
class AddlShellCommands(CommandSet): class AddlShellCommands(CommandSet):
def do_aram_get_all(self, _opts): def do_aram_get_all(self, _opts):
@@ -334,58 +379,25 @@ class ADF_ARAM(CardADF):
apdu_grp.add_argument( apdu_grp.add_argument(
'--apdu-filter', help='APDU filter: multiple groups of 8 hex bytes (4 byte CLA/INS/P1/P2 followed by 4 byte mask)') '--apdu-filter', help='APDU filter: multiple groups of 8 hex bytes (4 byte CLA/INS/P1/P2 followed by 4 byte mask)')
nfc_grp = store_ref_ar_do_parse.add_mutually_exclusive_group() nfc_grp = store_ref_ar_do_parse.add_mutually_exclusive_group()
nfc_grp.add_argument('--nfc-always', action='store_true',
help='NFC event access is allowed')
nfc_grp.add_argument('--nfc-never', action='store_true', nfc_grp.add_argument('--nfc-never', action='store_true',
help='NFC event access is not allowed') help='NFC event access is not allowed')
nfc_grp.add_argument('--nfc-always', action='store_true',
help='NFC event access is allowed')
store_ref_ar_do_parse.add_argument( store_ref_ar_do_parse.add_argument(
'--android-permissions', help='Android UICC Carrier Privilege Permissions (8 hex bytes)') '--android-permissions', help='Android UICC Carrier Privilege Permissions (8 hex bytes)')
@cmd2.with_argparser(store_ref_ar_do_parse) @cmd2.with_argparser(store_ref_ar_do_parse)
def do_aram_store_ref_ar_do(self, opts): def do_aram_store_ref_ar_do(self, opts):
"""Perform STORE DATA [Command-Store-REF-AR-DO] to store a (new) access rule.""" """Perform STORE DATA [Command-Store-REF-AR-DO] to store a (new) access rule."""
# REF res_do = ADF_ARAM.store_ref_ar_do(self._cmd.lchan.scc, opts.aid, opts.aid_empty, opts.device_app_id,
ref_do_content = [] opts.pkg_ref, opts.apdu_filter, opts.apdu_never, opts.apdu_always,
if opts.aid is not None: opts.nfc_always, opts.nfc_never, opts.android_permissions)
ref_do_content += [{'aid_ref_do': opts.aid}]
elif opts.aid_empty:
ref_do_content += [{'aid_ref_empty_do': None}]
ref_do_content += [{'dev_app_id_ref_do': opts.device_app_id}]
if opts.pkg_ref:
ref_do_content += [{'pkg_ref_do': {'package_name_string': opts.pkg_ref}}]
# AR
ar_do_content = []
if opts.apdu_never:
ar_do_content += [{'apdu_ar_do': {'generic_access_rule': 'never'}}]
elif opts.apdu_always:
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))
offset = 0
apdu_filter = []
while offset < len(opts.apdu_filter):
apdu_filter += [{'header': opts.apdu_filter[offset:offset+8],
'mask': opts.apdu_filter[offset+8:offset+16]}]
offset += 16 # Move offset to the beginning of the next apdu_filter object
ar_do_content += [{'apdu_ar_do': {'apdu_filter': apdu_filter}}]
if opts.nfc_always:
ar_do_content += [{'nfc_ar_do': {'nfc_event_access_rule': 'always'}}]
elif opts.nfc_never:
ar_do_content += [{'nfc_ar_do': {'nfc_event_access_rule': 'never'}}]
if opts.android_permissions:
ar_do_content += [{'perm_ar_do': {'permissions': opts.android_permissions}}]
d = [{'ref_ar_do': [{'ref_do': ref_do_content}, {'ar_do': ar_do_content}]}]
csrado = CommandStoreRefArDO()
csrado.from_val_dict(d)
res_do = ADF_ARAM.store_data(self._cmd.lchan.scc, csrado)
if res_do: if res_do:
self._cmd.poutput_json(res_do.to_dict()) self._cmd.poutput_json(res_do.to_dict())
def do_aram_delete_all(self, _opts): def do_aram_delete_all(self, _opts):
"""Perform STORE DATA [Command-Delete[all]] to delete all access rules.""" """Perform STORE DATA [Command-Delete[all]] to delete all access rules."""
deldo = CommandDelete() res_do = ADF_ARAM.aram_delete_all(self._cmd.lchan.scc)
res_do = ADF_ARAM.store_data(self._cmd.lchan.scc, deldo)
if res_do: if res_do:
self._cmd.poutput_json(res_do.to_dict()) self._cmd.poutput_json(res_do.to_dict())
@@ -394,7 +406,6 @@ class ADF_ARAM(CardADF):
(Proprietary feature that is specific to sysmocom's fork of Bertrand Martel’s ARA-M implementation.)""" (Proprietary feature that is specific to sysmocom's fork of Bertrand Martel’s ARA-M implementation.)"""
self._cmd.lchan.scc.send_apdu_checksw('80e2900001A1', '9000') self._cmd.lchan.scc.send_apdu_checksw('80e2900001A1', '9000')
# SEAC v1.1 Section 4.1.2.2 + 5.1.2.2 # SEAC v1.1 Section 4.1.2.2 + 5.1.2.2
sw_aram = { sw_aram = {
'ARA-M': { 'ARA-M': {
+627
View File
@@ -0,0 +1,627 @@
# -*- coding: utf-8 -*-
"""Bearer Independent Protocol relay"""
#
# (C) 2023-2024 by Harald Welte <laforge@osmocom.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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/>.
# A ProactiveHandler with TCP sockets that backs the BIP channels,
# so a card can run its own IP session (SCP81/HTTPS, CAT_TP, ...)
#
# Currently used by pySim-smpp2sim.py which connects the SMS path to its SMPP server.
# Other drivers can pass their own sinks:
#
# handler = Proact(data_available_sink=..., sms_sink=...)
# tp = init_reader(opts, proactive_handler=handler)
import logging
import socket
import threading
import time
from osmocom.utils import b2h, h2b
from pySim.transport import ProactiveHandler
from pySim.sms import SMS_DELIVER, SMS_SUBMIT, AddressField
from pySim.cat import (ProactiveCommand, SendShortMessage, SMS_TPDU, SMSPPDownload,
BearerDescription, DeviceIdentities, Address, OtherAddress,
UiccTransportLevel, BufferSize, ChannelStatus, ChannelData,
ChannelDataLength, EventList, EventDownload, Result,
CommandDetails, LocationInformation)
logger = logging.getLogger(__name__)
# PROVIDE LOCAL INFORMATION location, GERAN TS 31.111 8.19.1
# - 3 byte PLMN of TS 24.008 10.5.1.3 -> 262-01
# - 2 byte LAC and a 2 byte cid.
DEFAULT_LOCATION = h2b('62f21000010001')
def terminal_profile(num_channels: int = 7) -> bytes:
"""TERMINAL PROFILE for what we implement, TS 102 223 5.2 and annex T.
Annex T table T.1 lists what a Connected Entity, a CAT client that is not the modem
which is pretty much what we are, may announce, and its inverse is what only a modem may announce.
"""
if not 0 <= num_channels <= ProactChannels.MAX_CHANNELS:
raise ValueError('num_channels must be 0..%u' % ProactChannels.MAX_CHANNELS)
profile = bytearray(32)
# 1 (Download): b1 profile download, b2+b5 SMS-PP data download. Both of the latter, per the
# note in TS 31.111 5.2: "several bits may need to be set to 1 for the support of the same
# facility ... because of backward compatibility with SAT". The relay is OTA over SMS-PP.
profile[0] = 0x01 | 0x02 | 0x10
profile[1] = 0x01 # 2 (Other): b1 command result
profile[2] = 0x80 # 3: b8 REFRESH (empty result is a valid answer, 6.4.7)
profile[3] = 0x02 # 4: b2 SEND SHORT MESSAGE (the OTA response path)
profile[4] = 0x01 # 5: b1 SET UP EVENT LIST
profile[5] = 0x04 | 0x08 # 6: b3 Event Data available, b4 Event Channel status
# 12 (class "e"): b1..b5 OPEN CHANNEL, CLOSE CHANNEL, RECEIVE DATA, SEND DATA, GET CHANNEL
# STATUS.
profile[11] = 0x1f
# 13 (class "e" supported bearers): b2 GPRS, and b6..b8 the number of channels.
profile[12] = 0x02 | (num_channels << 5)
profile[13] = 0x40 | 0x20 # 14: b6 no display capability, b7 no keypad available
profile[16] = 0x01 # 15: b1 TCP, UICC in client mode, remote connection
return bytes(profile)
class ProactChannel:
"""One BIP channel, TS 102 223 class "e", backed by a blocking TCP socket.
Created by ProactChannels.channel_create(). A reader thread fills the Rx buffer from the
socket, the Proact handlers drain it (RECEIVE DATA) and write to it (SEND DATA). Payload
is opaque, TLS or CAT_TP run on the card.
Args:
channels: the owning ProactChannels, notified of data arrival and of close()
chan_nr: channel number 1..7 as used in the Device identities
"""
# Why blocking sockets and not Twisted endpoints, considering we have twisted?
# The proactive-command loop lives in a blocking while-loop,
# "pySim.transport.LinkBase.send_apdu_checksw" that runs on the Twisted reactor thread.
# A Twisted async TCP client only makes any progress when the reactor uhh... reacts, but
# the reactor is stuck in that loop for the whole proactive session -> the
# connectProtocol() Deferred never fires while we are handling OPEN/SEND/RECEIVE CHANNEL.
# Plain blocking sockets just work: connect() in handle_OpenChannel, send() in
# handle_SendData, recv() feeding a buffer for handle_ReceiveData. No need to make it
# harder than it has to be to handle the "massive" T0 bandwidth..
# how much we try to read off the socket per recv()
RECV_CHUNK = 4096
def __init__(self, channels: 'ProactChannels', chan_nr: int):
self.channels = channels
self.chan_nr = chan_nr
self.sock = None
# TS 102 223 says the terminal keeps an Rx buffer per channel; RECEIVE
# DATA drains it, and it is filled asynchronously as the peer sends.
self.rx_buf = bytearray()
self._rx_lock = threading.Lock()
self._reader = None
self._closing = False
self.peer_closed = False
def connect(self, host: str, port: int, timeout: float = 10.0):
"""Open the blocking TCP socket and start the background Rx reader."""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
s.settimeout(timeout)
s.connect((host, port))
# Back to blocking mode for the reader thread.
# CLOSE CHANNEL unblocks the pending recv() via shutdown().
s.settimeout(None)
except OSError:
s.close()
raise
self.sock = s
self._reader = threading.Thread(target=self._rx_loop,
name='bip-rx-%d' % self.chan_nr, daemon=True)
self._reader.start()
def _rx_loop(self):
"""Continuously read from the socket into rx_buf, like a real ME.
TS 102 223 7.5.10.1 says the event is raised 'only if the targeted channel buffer is
empty when new data arrives in it', so the data available hook fires on the
empty->non-empty transition only. That is enough: every RECEIVE DATA response tells
the card how many bytes remain, so it keeps fetching until the buffer is empty, and
the next event restarts it when more data arrives."""
while not self._closing:
try:
data = self.sock.recv(self.RECV_CHUNK)
except (OSError, ValueError):
break
if not data:
self.peer_closed = True
break
with self._rx_lock:
was_empty = len(self.rx_buf) == 0
self.rx_buf.extend(data)
if was_empty and not self._closing:
self.channels.notify_data_available(self)
def send(self, data: bytes):
"""Tx, write bytes to the socket == SEND DATA"""
self.sock.sendall(data)
def available_rx(self) -> int:
"""Number of bytes waiting in the Rx buffer, what RECEIVE DATA can return right now."""
with self._rx_lock:
return len(self.rx_buf)
def take_rx(self, n: int):
"""Take up to n bytes out of the Rx buffer. Returns (bytes, bytes still remaining)."""
with self._rx_lock:
chunk = bytes(self.rx_buf[:n])
del self.rx_buf[:n]
remaining = len(self.rx_buf)
return chunk, remaining
def wait_rx(self, timeout: float) -> int:
"""wait up to timeout seconds until the rxbuf has data
returns the number of bytes available
Cards have a "data available" event, card free callers use
this to wait for the echoed bytes."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
avail = self.available_rx()
if avail or self.peer_closed:
return avail
time.sleep(0.005)
return self.available_rx()
def close(self):
"""Close channel: stop reader, close socket, drop bookkeeping."""
self._closing = True
if self.sock is not None:
try:
self.sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
try:
self.sock.close()
except OSError:
pass
# CLOSE CHANNEL synchronously handled inside the rx reader thread
# (data-available -> ENVELOPE -> FETCH -> handle_CloseChannel -> close),
# so close() can be called on the reader thread.
# Joining self raises "cannot join current thread" so better skip i..
# setting _closing + shutting down the socket already makes _rx_loop
# return on the next iteration anyway.
if self._reader is not None and self._reader is not threading.current_thread():
self._reader.join(timeout=1.0)
self.channels.channel_delete(self.chan_nr)
class ProactChannels:
"""The open BIP channels of one terminal, keyed by channel number.
Args:
on_data_available: callback(chan: ProactChannel), invoked from the channel's reader
thread when data arrives in an empty Rx buffer. Proact turns it into an
ENVELOPE EVENT DOWNLOAD (data available).
"""
# TS 102 223 8.56 channel identifier in 3 bits as "1 to 7", 0 == no channel available
# TERMINAL PROFILE has to agree with byte 13 , "number of channels supported by terminal"
MAX_CHANNELS = 7
def __init__(self, on_data_available=None):
self.channels = {}
self._on_data_available = on_data_available
def channel_create(self) -> ProactChannel:
"""Create a new proactive channel, allocating its integer number."""
for i in range(1, self.MAX_CHANNELS + 1):
if not i in self.channels:
self.channels[i] = ProactChannel(self, i)
return self.channels[i]
raise ValueError('Cannot allocate another channel: All channels active')
def channel_delete(self, chan_nr: int):
"""Forget a channel, called by ProactChannel.close()."""
self.channels.pop(chan_nr, None)
def notify_data_available(self, chan: ProactChannel):
"""Run the on_data_available callback for chan, if one was given."""
if self._on_data_available:
self._on_data_available(chan)
class Proact(ProactiveHandler):
"""ProactiveHandler that answers the BIP proactive commands with TCP sockets.
The transport calls the handle_* methods with the decoded proactive command and posts
the returned IE list as TERMINAL RESPONSE.
Args:
data_available_sink: callback(envelope_hex: str), called from a channel reader thread
with an encoded ENVELOPE EVENT DOWNLOAD (data available). The caller forwards it
to the card with the ENVELOPE command, the card then FETCHes RECEIVE DATA.
None: the event is only logged (card free / test mode).
sms_sink: callback(pdu), called with the SMPP deliver_sm of a SEND SHORT MESSAGE
the card issued; pySim-smpp2sim.py hands it to its SMPP server.
None: the SMS is logged and dropped.
location: Location information returned in PROVIDE LOCAL INFORMATION (location).
"""
def __init__(self, data_available_sink=None, sms_sink=None, location: bytes = DEFAULT_LOCATION):
self.data_available_sink = data_available_sink
self.sms_sink = sms_sink
self.location = location
self.channels = ProactChannels(on_data_available=self._on_channel_data_available)
def handle_ProvideLocalInformation(self, pcmd: ProactiveCommand):
"""only location
TS 102 223 6.8.7 says TERMINAL RESPONSE to PROVIDE LOCAL INFORMATION "shall"
contain the data object the command qualifier (6.6.15) asked for. At least answer '00',
location information, usually requested.
answering "terminal currently unable to process - no service", which is a handset
out of coverage makes SJA5 believe it and postpones the entire session!
it registers a location status event, starts a ten minute timer and waits for coverage."""
cmd_det_ie = Proact._find_first_element_of_type(pcmd.children, CommandDetails)
if cmd_det_ie is not None and cmd_det_ie.decoded['command_qualifier'] == 0x00:
return self.prepare_response(pcmd) + [LocationInformation(decoded=self.location)]
return self.prepare_response(pcmd)
def receive_fetch(self, pcmd: ProactiveCommand):
"""Answer anything this handler has no specific handler for.
A card coming up will usually issue PROVIDE LOCAL INFORMATION,
POLL INTERVAL or TIMER MANAGEMENT before it gets anywhere near a BIP channel,
whatever the TERMINAL PROFILE announces.
Note that this is not the spec-correct answer. TS 102 223 6.8.7
says a successful TERMINAL RESPONSE to PROVIDE LOCAL INFORMATION "shall" carry the
requested Local information data object, and 6.8.13/6.8.14 says the same for TIMER
MANAGEMENT, this returns empty results for all of them, which works with real cards.
Always "performed_successfully", never "command_beyond_terminal_capability" because
answering that to PROVIDE LOCAL INFORMATION makes a card refuse to open the session.
"""
logger.info("no handler for %s, answering performed_successfully",
type(pcmd.decoded).__name__)
return self.prepare_response(pcmd, 'performed_successfully')
@staticmethod
def _find_first_element_of_type(instlist, cls):
for i in instlist:
if isinstance(i, cls):
return i
return None
@staticmethod
def _channel_nr_from_dev_ids(dev_id_ie: DeviceIdentities) -> int:
"""Maps id like channel_1 -> channel number.
TS 102 223 Section 8.7 says low nibble is channel number,
channel-N = 0x21..0x27"""
dest = dev_id_ie.decoded['dest_dev_id']
return DeviceIdentities.DEV_IDS.inverse[dest] & 0x0f
def _channel_for(self, dev_id_ie: DeviceIdentities):
"""Resolve the ProactChannel addressed by a command dev id, or None"""
return self.channels.channels.get(self._channel_nr_from_dev_ids(dev_id_ie), None)
@staticmethod
def _channel_status(chan_nr: int, established: bool = True) -> str:
"""TS 102 223 Section 8.56 channel status value for the
default/network bearer:
- byte 3 low 3 bits = channel id
- bit 8 = link established
- byte 4 = 00 no further info"""
b3 = (0x80 if established else 0x00) | (chan_nr & 0x07)
return '%02x00' % b3
def _bip_response_head(self, pcmd: ProactiveCommand,
general_result: str = 'performed_successfully',
additional_information: str = ''):
"""CommandDetails / DeviceIdentities / Result head part of a BIP TERMINAL
RESPONSE. Built on prepare_response() but with two changes:
- Device identities forced source=terminal, dest=UICC.
TS 102 223 6.8.2 mandates for every TERMINAL RESPONSE
prepare_response() inverts the commands device id, which is
right for a uicc->terminal command but would yield a wrong
channel_N->UICC for the channel addressed BIP commands.
- Result is recreated for non success cases. prepare_response()
hard codes empty "additional information", but for enum results
like BIP error -> AddlInfoBip the empty value cannot be encoded at
all, so we always ask prepare_response() for a success Result
and swap for a properly encoded one here."""
head = self.prepare_response(pcmd, 'performed_successfully')
for i, ie in enumerate(head):
if isinstance(ie, DeviceIdentities):
head[i] = DeviceIdentities(decoded={'source_dev_id': 'terminal',
'dest_dev_id': 'uicc'})
elif isinstance(ie, Result) and general_result != 'performed_successfully':
res = Result()
res.from_dict({'result': {'general_result': general_result,
'additional_information': additional_information}})
head[i] = res
return head
def _build_data_available_envelope(self, chan: ProactChannel) -> bytes:
"""TS 102 223 7.5.10.2 ENVELOPE EVENT DOWNLOAD
Event list, Device id terminal->UICC, Channel status,
Channel data length (bytes available or FF for > 255)."""
avail = min(chan.available_rx(), 0xff)
ed = EventDownload(children=[
EventList(decoded=['data_available']),
DeviceIdentities(decoded={'source_dev_id': 'terminal', 'dest_dev_id': 'uicc'}),
ChannelStatus(decoded=self._channel_status(chan.chan_nr)),
ChannelDataLength(decoded=avail),
])
return ed.to_tlv()
def _on_channel_data_available(self, chan: ProactChannel):
"""rx reader thread hook: socket data arrived while the channel buffer
was empty. card uses ENVELOPE EVENT DOWNLOAD + responds by FETCHing RECEIVE DATA
proactive command. Card free only builds and logs"""
envelope_hex = b2h(self._build_data_available_envelope(chan))
logger.info("channel %u: %u byte(s) available -> ENVELOPE(Data available) %s",
chan.chan_nr, chan.available_rx(), envelope_hex)
if self.data_available_sink:
self.data_available_sink(envelope_hex)
# handle_*: called by the transport with the decoded proactive command, the returned IE
# list becomes the TERMINAL RESPONSE.
def handle_SendShortMessage(self, pcmd: ProactiveCommand):
# {'smspp_download': [{'device_identities': {'source_dev_id': 'network',
# 'dest_dev_id': 'uicc'}},
# {'address': {'ton_npi': {'ext': True,
# 'type_of_number': 'international',
# 'numbering_plan_id': 'isdn_e164'},
# 'call_number': '79'}},
# {'sms_tpdu': {'tpdu': '40048111227ff6407070611535004d02700000481516011212000001fe4c0943aea42e45021c078ae06c66afc09303608874b72f58bacadb0dcf665c29349c799fbb522e61709c9baf1890015e8e8e196e36153106c8b92f95153774'}}
# ]}
"""SEND SHORT MESSAGE: hand the MO-SMS to sms_sink, answer with success so the card
continues with the next part of a multi part response."""
logger.info("SendShortMessage")
logger.info(pcmd)
# Relevant parts in pcmd: Address, SMS_TPDU
addr_ie = Proact._find_first_element_of_type(pcmd.children, Address)
sms_tpdu_ie = Proact._find_first_element_of_type(pcmd.children, SMS_TPDU)
raw_tpdu = sms_tpdu_ie.decoded['tpdu']
submit = SMS_SUBMIT.from_bytes(raw_tpdu)
submit.tp_da = AddressField(addr_ie.decoded['call_number'], addr_ie.decoded['ton_npi']['type_of_number'],
addr_ie.decoded['ton_npi']['numbering_plan_id'])
logger.info(submit)
self.send_sms_via_smpp(submit)
# Return a successful TERMINAL RESPONSE.
# This is important:
# - without it the transport cannot complete the proactive command
# - for a multi part OTA response, the card would never be asked to give us
# the remaining SMS chunks.
# 'pcmd' is a decoded SendShortMessage IE, which contains CommandDetails and
# DeviceIdentities that prepare_response() echoes/inverts.
return self.prepare_response(pcmd)
def handle_OpenChannel(self, pcmd: ProactiveCommand):
"""OPEN CHANNEL: connect a TCP socket to the given address and port, allocate a
channel number and report it in the Channel status of the response."""
# {'open_channel': [{'command_details': {'command_number': 1,
# 'type_of_command': 'open_channel',
# 'command_qualifier': 3}},
# {'device_identities': {'source_dev_id': 'uicc',
# 'dest_dev_id': 'terminal'}},
# {'bearer_description': {'bearer_type': 'default',
# 'bearer_parameters': ''}},
# {'buffer_size': 1024},
# {'uicc_transport_level': {'protocol_type': 'tcp_uicc_client_remote',
# 'port_number': 32768}},
# {'other_address': {'type_of_address': 'ipv4',
# 'address': '01020304'}}
# ]}
logger.info("OpenChannel")
logger.info(pcmd)
transp_lvl_ie = Proact._find_first_element_of_type(pcmd.children, UiccTransportLevel)
other_addr_ie = Proact._find_first_element_of_type(pcmd.children, OtherAddress)
bearer_desc_ie = Proact._find_first_element_of_type(pcmd.children, BearerDescription)
buffer_size_ie = Proact._find_first_element_of_type(pcmd.children, BufferSize)
def refuse(additional_information: str, chan_nr: int = 0):
"""TERMINAL RESPONSE refusing the OPEN CHANNEL
- always a BIP error, only the cause byte of TS 102 223 8.12.11 differs
- chan_nr 0 -> "no channel available" in the Channel status, 8.56
- 6.8.18, 6.8.20, 6.8.21 want chan status, Bearer desc and buf size
in a successful or unsuccessful response
"""
ies = [ChannelStatus(decoded=self._channel_status(chan_nr, established=False))]
ies += [ie for ie in (bearer_desc_ie, buffer_size_ie) if ie is not None]
return self._bip_response_head(pcmd, 'bearer_independent_protocol_error',
additional_information) + ies
# UICC/terminal interface transport level is Optional, TS 102 223 6.6.27.x. Absent means
# the CAT application runs its own network and transport layer, which we do not do.
if transp_lvl_ie is None or transp_lvl_ie.decoded['protocol_type'] != 'tcp_uicc_client_remote':
logger.warning("OpenChannel: unsupported UICC/terminal interface transport level (%s) "
"-> refusing", transp_lvl_ie.decoded if transp_lvl_ie else '(absent)')
return refuse('requested_uicc_if_transp_level_not_available')
if other_addr_ie is None or other_addr_ie.decoded.get('type_of_address', None) != 'ipv4':
# No cause byte fits a wrong address family. '06' is about the transport level data
# object, and 8.12.11 leaves '14' ("IPv4 only allowed") reserved by 3GPP, so '00'.
logger.warning("OpenChannel: unsupported data destination address (%s) -> refusing",
other_addr_ie.decoded if other_addr_ie else '(absent)')
return refuse('no_specific_cause')
addr_bytes = h2b(other_addr_ie.decoded['address']) if isinstance(
other_addr_ie.decoded['address'], str) else other_addr_ie.decoded['address']
ipv4_str = '%u.%u.%u.%u' % (addr_bytes[0], addr_bytes[1], addr_bytes[2], addr_bytes[3])
port_nr = transp_lvl_ie.decoded['port_number']
logger.info("OpenChannel: connecting to %s:%u", ipv4_str, port_nr)
try:
channel = self.channels.channel_create()
except ValueError:
# TS 102 223 6.4.27.2 and 6.4.27.3: no channel left -> BIP error
logger.warning("OpenChannel: all %u channels are in use -> refusing",
len(self.channels.channels))
return refuse('no_channel_availabile')
# yes, blocking connect()
try:
channel.connect(ipv4_str, port_nr)
except OSError as e:
logger.warning("OpenChannel: connect to %s:%u failed: %s", ipv4_str, port_nr, e)
self.channels.channel_delete(channel.chan_nr)
# TS 102 223 6.4.30 is the only clause naming a cause for a link that could not be
# established: BIP error, channel closed. 6.4.27.4 lists no error cases at all.
return refuse('channel_closed', channel.chan_nr)
# Terminal Response example: [
# {'command_details': {'command_number': 1,
# 'type_of_command': 'open_channel',
# 'command_qualifier': 3}},
# {'device_identities': {'source_dev_id': 'terminal', 'dest_dev_id': 'uicc'}},
# {'result': {'general_result': 'performed_successfully', 'additional_information': ''}},
# {'channel_status': '8100'},
# {'bearer_description': {'bearer_type': 'default', 'bearer_parameters': ''}},
# {'buffer_size': 1024}
# ]
return self._bip_response_head(pcmd) + [
ChannelStatus(decoded=self._channel_status(channel.chan_nr)),
bearer_desc_ie, buffer_size_ie]
def handle_CloseChannel(self, pcmd: ProactiveCommand):
"""CLOSE CHANNEL: close the socket of the addressed channel and free its number."""
logger.info("CloseChannel")
logger.info(pcmd)
dev_id_ie = Proact._find_first_element_of_type(pcmd.children, DeviceIdentities)
chan = self._channel_for(dev_id_ie)
if chan is None:
# channel closed / invalid
return self._bip_response_head(pcmd, 'bearer_independent_protocol_error',
'channel_id_not_valid')
chan.close()
return self._bip_response_head(pcmd)
def handle_ReceiveData(self, pcmd: ProactiveCommand):
"""RECEIVE DATA: the card fetches up to Channel data length bytes from the Rx buffer
of the addressed channel, the response also carries how many bytes remain."""
# {'receive_data': [{'command_details': {'command_number': 1,
# 'type_of_command': 'receive_data',
# 'command_qualifier': 0}},
# {'device_identities': {'source_dev_id': 'uicc',
# 'dest_dev_id': 'channel_1'}},
# {'channel_data_length': 9}
# ]}
logger.info("ReceiveData")
logger.info(pcmd)
dev_id_ie = Proact._find_first_element_of_type(pcmd.children, DeviceIdentities)
req_len_ie = Proact._find_first_element_of_type(pcmd.children, ChannelDataLength)
chan = self._channel_for(dev_id_ie)
if chan is None:
return self._bip_response_head(pcmd, 'bearer_independent_protocol_error',
'channel_id_not_valid')
# TS 102 223 8.54: RECEIVE DATA contains the requested count the card wants
requested = req_len_ie.decoded if req_len_ie is not None else chan.available_rx()
data, remaining = chan.take_rx(requested)
# TS 102 223 6.4.29:
# - return data available in the Rx buffer + num bytes still remaining (FF if > 255)
# - if fewer than requested available terminal must NOT wait, report and returns what we have
general_result = 'performed_successfully'
if len(data) < requested:
general_result = 'performed_with_missing_information'
# Terminal Response example: [
# {'command_details': {'command_number': 1,
# 'type_of_command': 'receive_data',
# 'command_qualifier': 0}},
# {'device_identities': {'source_dev_id': 'terminal', 'dest_dev_id': 'uicc'}},
# {'result': {'general_result': 'performed_successfully', 'additional_information': ''}},
# {'channel_data': '16030100040e000000'},
# {'channel_data_length': 0}
# ]
return self._bip_response_head(pcmd, general_result) + [
ChannelData(decoded=b2h(data)),
ChannelDataLength(decoded=min(remaining, 0xff))]
def handle_SendData(self, pcmd: ProactiveCommand):
"""SEND DATA: write the Channel data of the command to the socket of the addressed
channel."""
# {'send_data': [{'command_details': {'command_number': 1,
# 'type_of_command': 'send_data',
# 'command_qualifier': 1}},
# {'device_identities': {'source_dev_id': 'uicc',
# 'dest_dev_id': 'channel_1'}},
# {'channel_data': '160301003c010000380303d0f45e12b52ce5bb522750dd037738195334c87a46a847fe2b6886cada9ea6bf00000a00ae008c008b00b0002c010000050001000101'}
# ]}
logger.info("SendData")
logger.info(pcmd)
dev_id_ie = Proact._find_first_element_of_type(pcmd.children, DeviceIdentities)
chan_data_ie = Proact._find_first_element_of_type(pcmd.children, ChannelData)
chan = self._channel_for(dev_id_ie)
if chan is None:
return self._bip_response_head(pcmd, 'bearer_independent_protocol_error',
'channel_id_not_valid')
# lets accept hexstrings as well
payload = chan_data_ie.decoded
if isinstance(payload, str):
payload = h2b(payload)
# command_qualifier bit 1 selects 'send immediately' / Tx-buffer store and forward
# For TCP stream all we have is a socket and TCP takes care of segmentation,
# so just send.
chan.send(payload)
# Terminal Response example: [
# {'command_details': {'command_number': 1,
# 'type_of_command': 'send_data',
# 'command_qualifier': 1}},
# {'device_identities': {'source_dev_id': 'terminal', 'dest_dev_id': 'uicc'}},
# {'result': {'general_result': 'performed_successfully', 'additional_information': ''}},
# {'channel_data_length': 255}
# ]
# TS 102 223 6.4.30 / 8.54 Channel data length = free space tx buf; FF == > 255 available
return self._bip_response_head(pcmd) + [ChannelDataLength(decoded=255)]
def handle_SetUpEventList(self, pcmd: ProactiveCommand):
"""SET UP EVENT LIST: acknowledged, data available and channel status are always on."""
# {'set_up_event_list': [{'command_details': {'command_number': 1,
# 'type_of_command': 'set_up_event_list',
# 'command_qualifier': 0}},
# {'device_identities': {'source_dev_id': 'uicc',
# 'dest_dev_id': 'terminal'}},
# {'event_list': ['data_available', 'channel_status']}
# ]}
logger.info("SetUpEventList")
logger.info(pcmd)
# Terminal Response example: [
# {'command_details': {'command_number': 1,
# 'type_of_command': 'set_up_event_list',
# 'command_qualifier': 0}},
# {'device_identities': {'source_dev_id': 'terminal', 'dest_dev_id': 'uicc'}},
# {'result': {'general_result': 'performed_successfully', 'additional_information': ''}}
# ]
return self.prepare_response(pcmd)
def getChannelStatus(self, pcmd: ProactiveCommand):
logger.info("GetChannelStatus")
logger.info(pcmd)
return self.prepare_response(pcmd) + []
def send_sms_via_smpp(self, submit: SMS_SUBMIT):
# while in a normal network the phone/ME would *submit* a message to the SMSC,
# we are actually emulating the SMSC itself, so we must *deliver* the message
# to the ESME
deliver = SMS_DELIVER.from_submit(submit)
deliver_smpp = deliver.to_smpp()
if self.sms_sink is None:
logger.info('no sms_sink: dropping MO-SMS %s', deliver_smpp)
return
self.sms_sink(deliver_smpp)
# # obtain the connection/binding of system_id to be used for delivering MO-SMS to the ESME
# connection = smpp_server.getBoundConnections[system_id].getNextBindingForDelivery()
# connection.sendDataRequest(deliver_smpp)
+69 -6
View File
@@ -33,10 +33,12 @@ from Cryptodome.Cipher import AES
from osmocom.utils import h2b, b2h from osmocom.utils import h2b, b2h
from pySim.log import PySimLogger from pySim.log import PySimLogger
import os
import abc import abc
import csv import csv
import logging import logging
import yaml import yaml
import argparse
log = PySimLogger.get(__name__) log = PySimLogger.get(__name__)
@@ -130,6 +132,31 @@ class CardKeyFieldCryptor:
cipher = AES.new(h2b(self.transport_keys[field_name.upper()]), AES.MODE_CBC, self.__IV) cipher = AES.new(h2b(self.transport_keys[field_name.upper()]), AES.MODE_CBC, self.__IV)
return b2h(cipher.encrypt(h2b(plaintext_val))) return b2h(cipher.encrypt(h2b(plaintext_val)))
@staticmethod
def argparse_add_args(arg_parser: argparse.ArgumentParser):
arg_parser.add_argument('--column-key', metavar='FIELD:AES_KEY_HEX', default=[], action='append',
help='per-column AES transport key', dest='column_key')
# Depprecated argument, replaced by --column-key (see above)
arg_parser.add_argument('--csv-column-key', metavar='FIELD:AES_KEY_HEX', default=[], action='append',
help=argparse.SUPPRESS, dest='column_key')
@staticmethod
def transport_keys_from_opts(opts: argparse.Namespace) -> dict:
"""
Transport keys are passed via the commandline using the '--column-key' option. Each column requires a
dedicated transport key. This method can be used to extract the column keys parameters from the commandline
options into a dict that can be directly passed to the construtor with the transport_keys argument.
Args:
opts: parsed commandline options (Namespace)
"""
transport_keys = {}
for par in opts.column_key:
name, key = par.split(':')
transport_keys[name] = key
return transport_keys
class CardKeyProvider(abc.ABC): class CardKeyProvider(abc.ABC):
"""Base class, not containing any concrete implementation.""" """Base class, not containing any concrete implementation."""
@@ -148,24 +175,33 @@ class CardKeyProvider(abc.ABC):
fond None shall be returned. fond None shall be returned.
""" """
@staticmethod
def argparse_add_args(arg_parser: argparse.ArgumentParser):
"""
Add the commandline arguments relevant for this card key provider.
Args:
arg_parser : argument parser group
"""
def __str__(self): def __str__(self):
return type(self).__name__ return type(self).__name__
class CardKeyProviderCsv(CardKeyProvider): class CardKeyProviderCsv(CardKeyProvider):
"""Card key provider implementation that allows to query against a specified CSV file.""" """Card key provider implementation that allows to query against a specified CSV file."""
def __init__(self, csv_filename: str, transport_keys: dict): def __init__(self, csv_filename: str, field_cryptor: CardKeyFieldCryptor):
""" """
Args: Args:
csv_filename : file name (path) of CSV file containing card-individual key/data csv_filename : file name (path) of CSV file containing card-individual key/data
transport_keys : (see class CardKeyFieldCryptor) field_cryptor : (see class CardKeyFieldCryptor)
""" """
log.info("Using CSV file as card key data source: %s" % csv_filename) log.info("Using CSV file as card key data source: %s" % csv_filename)
self.csv_file = open(csv_filename, 'r') self.csv_file = open(csv_filename, 'r')
if not self.csv_file: if not self.csv_file:
raise RuntimeError("Could not open CSV file '%s'" % csv_filename) raise RuntimeError("Could not open CSV file '%s'" % csv_filename)
self.csv_filename = csv_filename self.csv_filename = csv_filename
self.crypt = CardKeyFieldCryptor(transport_keys) self.crypt = field_cryptor
def get(self, fields: List[str], key: str, value: str) -> Dict[str, str]: def get(self, fields: List[str], key: str, value: str) -> Dict[str, str]:
self.csv_file.seek(0) self.csv_file.seek(0)
@@ -188,14 +224,20 @@ class CardKeyProviderCsv(CardKeyProvider):
return None return None
return return_dict return return_dict
@staticmethod
def argparse_add_args(arg_parser: argparse.ArgumentParser):
arg_parser.add_argument('--csv', metavar='FILE',
default="~/.osmocom/pysim/card_data.csv",
help='Read card data from CSV file')
class CardKeyProviderPgsql(CardKeyProvider): class CardKeyProviderPgsql(CardKeyProvider):
"""Card key provider implementation that allows to query against a specified PostgreSQL database table.""" """Card key provider implementation that allows to query against a specified PostgreSQL database table."""
def __init__(self, config_filename: str, transport_keys: dict): def __init__(self, config_filename: str, field_cryptor: CardKeyFieldCryptor):
""" """
Args: Args:
config_filename : file name (path) of CSV file containing card-individual key/data config_filename : file name (path) of CSV file containing card-individual key/data
transport_keys : (see class CardKeyFieldCryptor) field_cryptor : (see class CardKeyFieldCryptor)
""" """
import psycopg2 import psycopg2
log.info("Using SQL database as card key data source: %s" % config_filename) log.info("Using SQL database as card key data source: %s" % config_filename)
@@ -212,7 +254,7 @@ class CardKeyProviderPgsql(CardKeyProvider):
host=config.get('host')) host=config.get('host'))
self.tables = config.get('table_names') self.tables = config.get('table_names')
log.info("Card key database tables: %s" % str(self.tables)) log.info("Card key database tables: %s" % str(self.tables))
self.crypt = CardKeyFieldCryptor(transport_keys) self.crypt = field_cryptor
def get(self, fields: List[str], key: str, value: str) -> Dict[str, str]: def get(self, fields: List[str], key: str, value: str) -> Dict[str, str]:
import psycopg2 import psycopg2
@@ -252,6 +294,11 @@ class CardKeyProviderPgsql(CardKeyProvider):
result[k] = self.crypt.decrypt_field(k, result.get(k)) result[k] = self.crypt.decrypt_field(k, result.get(k))
return result return result
@staticmethod
def argparse_add_args(arg_parser: argparse.ArgumentParser):
arg_parser.add_argument('--pgsql', metavar='FILE',
default="~/.osmocom/pysim/card_data_pgsql.cfg",
help='Read card data from PostgreSQL database (config file)')
def card_key_provider_register(provider: CardKeyProvider, provider_list=card_key_providers): def card_key_provider_register(provider: CardKeyProvider, provider_list=card_key_providers):
"""Register a new card key provider. """Register a new card key provider.
@@ -305,3 +352,19 @@ def card_key_provider_get_field(field: str, key: str, value: str, provider_list=
fields = [field] fields = [field]
result = card_key_provider_get(fields, key, value, card_key_providers) result = card_key_provider_get(fields, key, value, card_key_providers)
return result.get(field.upper()) return result.get(field.upper())
def card_key_provider_argparse_add_args(arg_parser: argparse.ArgumentParser):
"""Add card key provider commandline options to the given argument parser"""
card_key_group = arg_parser.add_argument_group('Card Key Provider Options')
CardKeyProviderCsv.argparse_add_args(card_key_group)
CardKeyProviderPgsql.argparse_add_args(card_key_group)
CardKeyFieldCryptor.argparse_add_args(card_key_group)
def card_key_provider_init(opts: argparse.Namespace):
"""Initialize card key provider depending on the user provided commandline options"""
transport_keys = CardKeyFieldCryptor.transport_keys_from_opts(opts)
card_key_field_cryptor = CardKeyFieldCryptor(transport_keys)
if os.path.isfile(os.path.expanduser(opts.csv)):
card_key_provider_register(CardKeyProviderCsv(os.path.expanduser(opts.csv), card_key_field_cryptor))
if os.path.isfile(os.path.expanduser(opts.pgsql)):
card_key_provider_register(CardKeyProviderPgsql(os.path.expanduser(opts.pgsql), card_key_field_cryptor))
+83 -8
View File
@@ -22,7 +22,7 @@ from typing import List
from bidict import bidict from bidict import bidict
from construct import Int8ub, Int16ub, Byte, BitsInteger from construct import Int8ub, Int16ub, Byte, BitsInteger
from construct import Struct, Enum, BitStruct, this from construct import Struct, Enum, BitStruct, this
from construct import Switch, GreedyRange, FlagsEnum from construct import Switch, GreedyRange, FlagsEnum, Adapter
from osmocom.tlv import TLV_IE, COMPR_TLV_IE, BER_TLV_IE, TLV_IE_Collection from osmocom.tlv import TLV_IE, COMPR_TLV_IE, BER_TLV_IE, TLV_IE_Collection
from osmocom.construct import PlmnAdapter, BcdAdapter, GsmStringAdapter, TonNpi, GsmString, Bytes, GreedyBytes from osmocom.construct import PlmnAdapter, BcdAdapter, GsmStringAdapter, TonNpi, GsmString, Bytes, GreedyBytes
from osmocom.utils import b2h, h2b from osmocom.utils import b2h, h2b
@@ -318,11 +318,58 @@ class FileList(COMPR_TLV_IE, tag=0x92):
# TS 102 223 Section 8.19 # TS 102 223 Section 8.19
class LocationInformation(COMPR_TLV_IE, tag=0x93): class LocationInformation(COMPR_TLV_IE, tag=0x93):
pass # 8.19: coding is per access technology, and the lengths differ (TS 131.111 8.19.1-.4: GERAN 7,
# UTRAN/E-UTRAN 9, NG-RAN 11) with nothing in the IE to say which -> keep the value opaque.
_construct = GreedyBytes
# TS 102 223 Section 8.20 class MobileIdentityAdapter(Adapter):
"""TS 124.008 section 10.5.1.4 figure 10.5.4 + table 10.5.4
NOT a plain BCD string:
- bits 1-3 type of identity + odd/even bit 4
- digit 1 in bits 5-8, following octets contain 2 digits, low nibble first
- if even length: high nibble of last octet 1111
So IMEI IE of 8 bytes is 15 digits + framing nibble."""
# Table 10.5.4 bits 321
TYPE_IMSI = 1
TYPE_IMEI = 2
TYPE_IMEISV = 3
def __init__(self, subcon, type_of_identity: int):
super().__init__(subcon)
self.type_of_identity = type_of_identity
def _decode(self, obj, context, path):
data = bytes(obj)
if not data:
return ''
# TS 24.008 figure 10.5.4: octet 3 holds type of identity (b1-3), odd/even (b4) and
# digit 1 in its high nibble, the remaining digits follow BCD swapped from octet 4
odd = bool(data[0] & 0x08) # bit 4: 1 = odd number of digits
digits = '%x' % (data[0] >> 4) # bits 5-8: digit 1
for octet in data[1:]:
digits += '%x%x' % (octet & 0x0f, octet >> 4)
if not odd:
digits = digits[:-1] # drop the 1111 end mark
return digits
def _encode(self, obj, context, path):
digits = str(obj)
odd = len(digits) % 2
first = (int(digits[0], 16) << 4) | (0x08 if odd else 0x00) | self.type_of_identity
rest = digits[1:] if odd else digits[1:] + 'f'
return bytes([first]) + bytes((int(rest[i+1], 16) << 4) | int(rest[i], 16)
for i in range(0, len(rest), 2))
# TS 102 223 Section 8.20, len is fixed at 8: "The IMEI is coded [..] as the
# value part of the Mobile Identity IE as specified in TS 124 008", and the
# IMEI itself is the 15 digits of TS 123 003.
class IMEI(COMPR_TLV_IE, tag=0x94): class IMEI(COMPR_TLV_IE, tag=0x94):
_construct = BcdAdapter(GreedyBytes) _test_de_encode = [
( '94081a32547698103254', '123456789012345' ),
]
_construct = MobileIdentityAdapter(GreedyBytes, MobileIdentityAdapter.TYPE_IMEI)
# TS 102 223 Section 8.21 # TS 102 223 Section 8.21
class HelpRequest(COMPR_TLV_IE, tag=0x95): class HelpRequest(COMPR_TLV_IE, tag=0x95):
@@ -536,9 +583,9 @@ class Aid(COMPR_TLV_IE, tag=0xAF):
# TS 102 223 Section 8.61 # TS 102 223 Section 8.61
class AccessTechnology(COMPR_TLV_IE, tag=0xBF): class AccessTechnology(COMPR_TLV_IE, tag=0xBF):
SingleAccessTech = Enum(Int8ub, gsm=0, tia_eia_533=1, tia_eia_136_270=2, utran=3, tetra=4, SingleAccessTech = Enum(Int8ub, gsm=0, tia_eia_553=1, tia_eia_136_270=2, utran=3, tetra=4,
tia_eia_95_b=5, cdma1000_1x=6, cdma2000_hrpd=7, eutran=8, tia_eia_95_b=5, cdma2000_1x=6, cdma2000_hrpd=7, eutran=8,
ehrpd=9, nr=0x0a) ehrpd=9, nr=0x0a, satellite_nr=0x0b, satellite_eutran=0x0c)
_construct = GreedyRange(SingleAccessTech) _construct = GreedyRange(SingleAccessTech)
# TS 102 223 Section 8.63 # TS 102 223 Section 8.63
@@ -596,6 +643,14 @@ class UtranEutranMeasurementQualifier(COMPR_TLV_IE, tag=0xE9):
eutran_inter_rat_utran=0x08, eutran_inter_rat_utran=0x08,
eutran_inter_rat_nr=0x09) eutran_inter_rat_nr=0x09)
# TS 102 223 Section 8.74, length is not fixed, because IMEISV is 16 digits per TS 123.003
# -> even count needs the '1111' end mark and is 9 bytes long
class IMEISV(COMPR_TLV_IE, tag=0xE2):
_test_de_encode = [
( 'e2091332547698103254f6', '1234567890123456' ),
]
_construct = MobileIdentityAdapter(GreedyBytes, MobileIdentityAdapter.TYPE_IMEISV)
# TS 102 223 Section 8.75 # TS 102 223 Section 8.75
class NetworkSearchMode(COMPR_TLV_IE, tag=0xE5): class NetworkSearchMode(COMPR_TLV_IE, tag=0xE5):
_construct = Enum(Int8ub, manual=0, automatic=1) _construct = Enum(Int8ub, manual=0, automatic=1)
@@ -729,8 +784,12 @@ class DnsServerAddress(COMPR_TLV_IE, tag=0xC0):
# TS 102 223 Section 8.105 # TS 102 223 Section 8.105
class SupportedRadioAccessTechnologies(COMPR_TLV_IE, tag=0xB4): class SupportedRadioAccessTechnologies(COMPR_TLV_IE, tag=0xB4):
# 2 bytes/entry:
# - technology of 8.61
# - state byte b1 is 0 disabled/1 enabled
# - b2-b8 RFU.
AccessTechTuple = Struct('technology'/AccessTechnology.SingleAccessTech, AccessTechTuple = Struct('technology'/AccessTechnology.SingleAccessTech,
'state'/FlagsEnum(Int8ub, enabled=0)) 'state'/FlagsEnum(Int8ub, enabled=1))
_construct = GreedyRange(AccessTechTuple) _construct = GreedyRange(AccessTechTuple)
# TS 102 223 Section 8.107 # TS 102 223 Section 8.107
@@ -763,6 +822,22 @@ class SMSPPDownload(BER_TLV_IE, tag=0xD1,
nested=[DeviceIdentities, Address, SMS_TPDU]): nested=[DeviceIdentities, Address, SMS_TPDU]):
pass pass
def sms_pp_download_envelope(tpdu, call_number: str = '0123456') -> SMSPPDownload:
"""TS 31.111 Section 7.1.1.2 wrap of a SMS-DELIVER TPDU in the ENVELOPE (SMS-PP Download)
call_number :
SMSC address to report, defined in TS 31.111 7.1.1.2 as
"the RP_Originating_Address of the Service Centre (TS-Service-Centre-Address, 3GPP TS 24.011)"
its presence is Conditional, and the note there says the UICC should be fine
if its missing, so for remote management its presence should suffice (?).
"""
return SMSPPDownload(children=[
DeviceIdentities(decoded={'source_dev_id': 'network', 'dest_dev_id': 'uicc'}),
Address(decoded={'ton_npi': {'ext': False, 'type_of_number': 'unknown',
'numbering_plan_id': 'unknown'},
'call_number': call_number}),
SMS_TPDU(decoded={'tpdu': b2h(tpdu.to_bytes())})])
# TS 101 220 Table 7.17 + 31.111 7.1.1.3 # TS 101 220 Table 7.17 + 31.111 7.1.1.3
class SMSCBDownload(BER_TLV_IE, tag=0xD2, class SMSCBDownload(BER_TLV_IE, tag=0xD2,
nested=[DeviceIdentities, CBSPage]): nested=[DeviceIdentities, CBSPage]):
+1 -1
View File
@@ -131,7 +131,7 @@ class EF_AD(TransparentEF):
desc='Administrative Data', size=(3, None), **kwargs): desc='Administrative Data', size=(3, None), **kwargs):
super().__init__(fid, sfid=sfid, name=name, desc=desc, size=size, **kwargs) super().__init__(fid, sfid=sfid, name=name, desc=desc, size=size, **kwargs)
self._construct = Struct( self._construct = Struct(
# Byte 1: Display Condition # Byte 1: MS operation mode
'ms_operation_mode'/Enum(Byte, self.OP_MODE), 'ms_operation_mode'/Enum(Byte, self.OP_MODE),
# Bytes 2-3: Additional information # Bytes 2-3: Additional information
'additional_info'/Bytes(2), 'additional_info'/Bytes(2),
+33 -5
View File
@@ -19,7 +19,7 @@ import abc
import requests import requests
import logging import logging
import json import json
from typing import Optional from typing import Optional, Tuple
import base64 import base64
from twisted.web.server import Request from twisted.web.server import Request
@@ -180,7 +180,7 @@ class JsonHttpApiFunction(abc.ABC):
# receives from the a requesting client. The same applies vice versa to class variables that have an "output_" # receives from the a requesting client. The same applies vice versa to class variables that have an "output_"
# prefix. # prefix.
# path of the API function (e.g. '/gsma/rsp2/es2plus/confirmOrder') # path of the API function (e.g. '/gsma/rsp2/es2plus/confirmOrder', see also method rewrite_url).
path = None path = None
# dictionary of input parameters. key is parameter name, value is ApiParam class # dictionary of input parameters. key is parameter name, value is ApiParam class
@@ -336,6 +336,22 @@ class JsonHttpApiFunction(abc.ABC):
output[p] = p_class.decode(v) output[p] = p_class.decode(v)
return output return output
def rewrite_url(self, data: dict, url: str) -> Tuple[dict, str]:
"""
Rewrite a static URL using information passed in the data dict. This method may be overloaded by a derived
class to allow fully dynamic URLs. The input parameters required for the URL rewriting may be passed using
data parameter. In case those parameters are additional parameters that are not intended to be passed to
the encode_client method later, they must be removed explcitly.
Args:
data: (see JsonHttpApiClient and JsonHttpApiServer)
url: statically generated URL string (see comment in JsonHttpApiClient)
"""
# This implementation is a placeholder in which we do not perform any URL rewriting. We just pass through data
# and url unmodified.
return data, url
class JsonHttpApiClient(): class JsonHttpApiClient():
def __init__(self, api_func: JsonHttpApiFunction, url_prefix: str, func_req_id: Optional[str], def __init__(self, api_func: JsonHttpApiFunction, url_prefix: str, func_req_id: Optional[str],
session: requests.Session): session: requests.Session):
@@ -352,8 +368,16 @@ class JsonHttpApiClient():
self.session = session self.session = session
def call(self, data: dict, func_call_id: Optional[str] = None, timeout=10) -> Optional[dict]: def call(self, data: dict, func_call_id: Optional[str] = None, timeout=10) -> Optional[dict]:
"""Make an API call to the HTTP API endpoint represented by this object. Input data is passed in `data` as """
json-serializable dict. Output data is returned as json-deserialized dict.""" Make an API call to the HTTP API endpoint represented by this object. Input data is passed in `data` as
json-serializable fields. `data` may also contain additional parameters required for URL rewriting (see
rewrite_url in class JsonHttpApiFunction). Output data is returned as json-deserialized dict.
Args:
data: Input data required to perform the request.
func_call_id: Function Call Identifier, if present a header field is generated automatically.
timeout: Maximum amount of time to wait for the request to complete.
"""
# In case a function caller ID is supplied, use it together with the stored function requestor ID to generate # In case a function caller ID is supplied, use it together with the stored function requestor ID to generate
# and prepend the header field according to SGP.22, section 6.5.1.1 and 6.5.1.3. (the presence of the header # and prepend the header field according to SGP.22, section 6.5.1.1 and 6.5.1.3. (the presence of the header
@@ -362,6 +386,11 @@ class JsonHttpApiClient():
data = {'header' : {'functionRequesterIdentifier': self.func_req_id, data = {'header' : {'functionRequesterIdentifier': self.func_req_id,
'functionCallIdentifier': func_call_id}} | data 'functionCallIdentifier': func_call_id}} | data
# The URL used for the HTTP request (see below) normally consists of the initially given url_prefix
# concatenated with the path defined by the JsonHttpApiFunction definition. This static URL path may be
# rewritten by rewrite_url method defined in the JsonHttpApiFunction.
data, url = self.api_func.rewrite_url(data, self.url_prefix + self.api_func.path)
# Encode the message (the presence of mandatory fields is checked during encoding) # Encode the message (the presence of mandatory fields is checked during encoding)
encoded = json.dumps(self.api_func.encode_client(data)) encoded = json.dumps(self.api_func.encode_client(data))
@@ -373,7 +402,6 @@ class JsonHttpApiClient():
req_headers.update(self.api_func.extra_http_req_headers) req_headers.update(self.api_func.extra_http_req_headers)
# Perform HTTP request # Perform HTTP request
url = self.url_prefix + self.api_func.path
logger.debug("HTTP REQ %s - hdr: %s '%s'" % (url, req_headers, encoded)) logger.debug("HTTP REQ %s - hdr: %s '%s'" % (url, req_headers, encoded))
response = self.session.request(self.api_func.http_method, url, data=encoded, headers=req_headers, timeout=timeout) response = self.session.request(self.api_func.http_method, url, data=encoded, headers=req_headers, timeout=timeout)
logger.debug("HTTP RSP-STS: [%u] hdr: %s" % (response.status_code, response.headers)) logger.debug("HTTP RSP-STS: [%u] hdr: %s" % (response.status_code, response.headers))
+55 -3
View File
@@ -34,7 +34,7 @@ from pySim import ts_102_222
from pySim.utils import dec_imsi from pySim.utils import dec_imsi
from pySim.ts_102_221 import FileDescriptor from pySim.ts_102_221 import FileDescriptor
from pySim.filesystem import CardADF, Path from pySim.filesystem import CardADF, Path
from pySim.ts_31_102 import ADF_USIM from pySim.ts_31_102 import ADF_USIM, EF_UST, EF_SUCI_Calc_Info
from pySim.ts_31_103 import ADF_ISIM from pySim.ts_31_103 import ADF_ISIM
from pySim.esim import compile_asn1_subdir from pySim.esim import compile_asn1_subdir
from pySim.esim.saip import templates from pySim.esim.saip import templates
@@ -441,7 +441,7 @@ class File:
elif k == 'fillFileContent': elif k == 'fillFileContent':
stream.write(v) stream.write(v)
else: else:
return ValueError("Unknown key '%s' in tuple list" % k) raise ValueError("Unknown key '%s' in tuple list" % k)
return stream.getvalue() return stream.getvalue()
def file_content_to_tuples(self, optimize:bool = False) -> List[Tuple]: def file_content_to_tuples(self, optimize:bool = False) -> List[Tuple]:
@@ -1079,6 +1079,13 @@ class SecurityDomainKey:
'keyVersionNumber': bytes([self.key_version_number]), 'keyVersionNumber': bytes([self.key_version_number]),
'keyComponents': [k.to_saip_dict() for k in self.key_components]} '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 ProfileElementSD(ProfileElement):
"""Class representing a securityDomain ProfileElement.""" """Class representing a securityDomain ProfileElement."""
type = 'securityDomain' type = 'securityDomain'
@@ -1719,7 +1726,52 @@ class ProfileElementSequence:
if 'BT' in ftype_list: if 'BT' in ftype_list:
svc_set.add('ber-tlv') svc_set.add('ber-tlv')
# FIXME:dfLinked files (scan all files, check for non-empty Fcp.linkPath presence of DFs) # FIXME:dfLinked files (scan all files, check for non-empty Fcp.linkPath presence of DFs)
# TODO: 5G related bits (derive from EF.UST or file presence?)
# 5G:
# - When SUCI is:
# - enabled (EF.UST 124 = true)
# AND
# - calculated in the USIM (EF.UST 125 = true),
# then eUICC-Mandatory-services needs 'get-identity'.
# - 'get-identity' implies that the eUICC must support ONE OF profile-A OR profile-B.
# (One might assume from this that, when SUCI-CalcInfo for USIM in DF.SAIP contains both key types, then no
# profile-A or B services need to be requested explicitly. However, the correct logic is:)
# - Iff the SUCI-CalcInfo for USIM (DF.SAIP) contains a key of profile-A ("identifier": 1),
# then eUICC-Mandatory-services needs 'profile-a-x25519'.
# - Same: profile-B ("identifier": 2) needs 'profile-b-p256'.
# - (When SUCI is calculated in the UE, then the eUICC does not need to provide any of these services.)
suci_in_usim_enabled = False
try:
f_ust = self.get_pe_for_type("usim").files["ef-ust"]
ust = EF_UST().decode_bin(f_ust.body)
suci_in_usim_enabled = ust[124]['activated'] and ust[125]['activated']
except (KeyError, AttributeError):
pass
if suci_in_usim_enabled:
svc_set.add('get-identity')
# now check for profile-a and profile-b presence
suci_calcinfo_has_profile_a = False
suci_calcinfo_has_profile_b = False
try:
f_sucici = self.get_pe_for_type("df-saip").files["ef-suci-calc-info-usim"]
sucici = EF_SUCI_Calc_Info().decode_bin(f_sucici.body) or {}
for prot_scheme in sucici['prot_scheme_id_list']:
if not isinstance(prot_scheme, dict):
continue
ps_id = prot_scheme["identifier"]
if ps_id == 1:
suci_calcinfo_has_profile_a = True
elif ps_id == 2:
suci_calcinfo_has_profile_b = True
except (KeyError, AttributeError):
pass
if suci_calcinfo_has_profile_a:
# The profile has a profile-A key, so require that
svc_set.add('profile-a-x25519')
if suci_calcinfo_has_profile_b:
# The profile has a profile-B key, so require that
svc_set.add('profile-b-p256')
hdr_pe = self.get_pe_for_type('header') hdr_pe = self.get_pe_for_type('header')
# patch in the 'manual' services from the existing list: # patch in the 'manual' services from the existing list:
for old_svc in hdr_pe.decoded['eUICC-Mandatory-services'].keys(): for old_svc in hdr_pe.decoded['eUICC-Mandatory-services'].keys():
+362
View File
@@ -0,0 +1,362 @@
"""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
from typing import Generator, Union
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
# a list of ConfigurableParameter classes and/or ConfigurableParameter class instances
ParamList = list[Union[type[ConfigurableParameter], ConfigurableParameter]]
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 = open('some_file', 'rb').read()
pes = ProfileElementSequence.from_der(der_input)
p = 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):
if isinstance(param, type):
self.param_cls = param
else:
self.param_cls = param.__class__
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 generator (e.g. iter(list_of_rows)) 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, 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_cls.validate_val(input_value)
p.param_cls.apply_val(pes, value)
except Exception as e:
raise ValueError(f'{p.param_cls.get_name()} fed by {p.src.name}: {e}') from e
pes.rebuild_mandatory_services()
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: ParamList, 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.add_values({audit_key: audit_val})
return upp_audit
def get_single_val(self, key, 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 = b2h(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):
"""Merge a plain dict of values into self, which is a dict of sets.
For example from
self == { 'a': {123} }
and
src == { 'a': 456, 'b': 789 }
then after this function call:
self == { 'a': {123, 456}, 'b': {789} }
"""
assert isinstance(src, dict)
for key, srcval in src.items():
dstvalset = self.get(key)
if dstvalset is None:
dstvalset = set()
self[key] = dstvalset
dstvalset.add(srcval)
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: ParamList):
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 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 b2h(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
+221
View File
@@ -0,0 +1,221 @@
# 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
def __init__(self, input_str:str):
"""Subclasses should call super().__init__(input_str) before evaluating self.input_str. Each subclass __init__()
may in turn manipulate self.input_str to apply expansions or decodings."""
self.input_str = input_str
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()
@classmethod
def from_str(cls, input_str:str):
"""compatibility with earlier version of ParamSource. Just use the constructor."""
return cls(input_str)
class ConstantSource(ParamSource):
"""one value for all"""
name = "constant"
def get_next(self, csv_row:dict=None):
return self.input_str
class InputExpandingParamSource(ParamSource):
def __init__(self, input_str:str):
super().__init__(input_str)
self.input_str = self.expand_input_str(self.input_str)
@classmethod
def expand_input_str(cls, input_str:str):
# user convenience syntax '0*32' becomes '00000000000000000000000000000000'
if "*" not in input_str:
return input_str
# re: "XX * 123" with optional spaces
tokens = re.split(r"([^ \t]+)[ \t]*\*[ \t]*([0-9]+)", input_str)
if len(tokens) < 3:
return input_str
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)
class DecimalRangeSource(InputExpandingParamSource):
"""abstract: decimal numbers with a value range"""
numeric_base = 10
def __init__(self, input_str:str=None, num_digits:int=None, first_value:int=None, last_value:int=None):
"""Constructor to set up values from a (user entered) string: DecimalRangeSource(input_str).
Constructor to set up values directly: DecimalRangeSource(num_digits=3, first_value=123, last_value=456)
num_digits produces leading zeros when first_value..last_value are shorter.
"""
assert ((input_str is not None and (num_digits, first_value, last_value) == (None, None, None))
or (input_str is None and None not in (num_digits, first_value, last_value)))
if input_str is not None:
super().__init__(input_str)
input_str = self.input_str
if ".." in input_str:
first_str, last_str = input_str.split('..')
first_str = first_str.strip()
last_str = last_str.strip()
else:
first_str = input_str.strip()
last_str = None
num_digits = len(first_str)
first_value = int(first_str)
last_value = int(last_str if last_str is not None else "9" * num_digits)
assert num_digits > 0
assert first_value <= last_value
self.num_digits = num_digits
self.first_value = first_value
self.last_value = last_value
def val_to_digit(self, val:int):
return "%0*d" % (self.num_digits, val) # pylint: disable=consider-using-f-string
class RandomSourceMixin:
random_impl = secrets.SystemRandom()
class RandomDigitSource(DecimalRangeSource, RandomSourceMixin):
"""return a different sequence of random decimal digits each"""
name = "random decimal digits"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.used_keys = set()
def get_next(self, csv_row:dict=None):
# try to generate random digits that are always different from previously produced random digits
for _ in range(10):
val = self.random_impl.randint(self.first_value, self.last_value)
if val not in self.used_keys:
break
self.used_keys.add(val)
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
def __init__(self, input_str:str):
super().__init__(input_str)
input_str = self.input_str
num_digits = len(input_str.strip())
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
self.used_keys = set()
def get_next(self, csv_row:dict=None):
# try to generate random bytes that are always different from previously produced random bytes
for _ in range(10):
val = self.random_impl.randbytes(self.num_digits // 2)
if val not in self.used_keys:
break
self.used_keys.add(val)
return b2h(val)
class IncDigitSource(DecimalRangeSource):
"""incrementing sequence of digits"""
name = "incrementing decimal digits"
def __init__(self, input_str:str=None, num_digits:int=None, first_value:int=None, last_value:int=None):
"""input_str: the range of values to iterate. Format: 'FIRST..LAST' (e.g. '0001..9999') or
just 'FIRST' (iterates to the maximum value for the given digit width). Leading zeros in
FIRST determine the digit width and are preserved in returned values."""
super().__init__(input_str, 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.first_value
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.last_value:
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, input_str:str):
"""input_str: the CSV column name to read values from.
The caller passes the current CSV row to get_next(), from which CsvSource picks the column matching
this name."""
super().__init__(input_str)
self.csv_column = self.input_str
def get_next(self, csv_row:dict=None):
val = None
if csv_row:
val = csv_row.get(self.csv_column)
if val is None:
raise ParamSourceUndefinedExn(f"no value for CSV column {self.csv_column!r}")
return val
+624 -107
View File
@@ -16,13 +16,24 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import abc import abc
import enum
import io import io
from typing import List, Tuple import re
from typing import List, Tuple, Generator, Optional
from construct.core import StreamError
from osmocom.tlv import camel_to_snake from osmocom.tlv import camel_to_snake
from pySim.utils import enc_iccid, enc_imsi, h2b, rpad, sanitize_iccid from osmocom.utils import hexstr
from pySim.esim.saip import ProfileElement, ProfileElementSequence from pySim.utils import enc_iccid, dec_iccid, enc_imsi, dec_imsi, h2b, b2h, rpad, sanitize_iccid
from pySim.ts_31_102 import EF_AD
from pySim.ts_51_011 import EF_SMSP from pySim.ts_51_011 import EF_SMSP
from pySim.esim.saip import param_source
from pySim.esim.saip import ProfileElement, ProfileElementSD, ProfileElementSequence
from pySim.esim.saip import SecurityDomainKey, SecurityDomainKeyComponent
from pySim.global_platform import KeyUsageQualifier, KeyType
def unrpad(s: hexstr, c='f') -> hexstr:
return hexstr(s.rstrip(c))
def remove_unwanted_tuples_from_list(l: List[Tuple], unwanted_keys: List[str]) -> List[Tuple]: def remove_unwanted_tuples_from_list(l: List[Tuple], unwanted_keys: List[str]) -> List[Tuple]:
"""In a list of tuples, remove all tuples whose first part equals 'unwanted_key'.""" """In a list of tuples, remove all tuples whose first part equals 'unwanted_key'."""
@@ -43,7 +54,6 @@ class ClassVarMeta(abc.ABCMeta):
x = super().__new__(metacls, name, bases, namespace) x = super().__new__(metacls, name, bases, namespace)
for k, v in kwargs.items(): for k, v in kwargs.items():
setattr(x, k, v) setattr(x, k, v)
setattr(x, 'name', camel_to_snake(name))
return x return x
class ConfigurableParameter(abc.ABC, metaclass=ClassVarMeta): class ConfigurableParameter(abc.ABC, metaclass=ClassVarMeta):
@@ -63,6 +73,7 @@ class ConfigurableParameter(abc.ABC, metaclass=ClassVarMeta):
min_len: minimum length of an input str; min_len = 4 min_len: minimum length of an input str; min_len = 4
max_len: maximum length of an input str; max_len = 8 max_len: maximum length of an input str; max_len = 8
allow_len: permit only specific lengths; allow_len = (8, 16, 32) allow_len: permit only specific lengths; allow_len = (8, 16, 32)
numeric_base: indicate hex / decimal, if any; numeric_base = None; numeric_base = 10; numeric_base = 16
Subclasses may change the meaning of these by overriding validate_val(), for example that the length counts Subclasses may change the meaning of these by overriding validate_val(), for example that the length counts
resulting bytes instead of a hexstring length. Most subclasses will be covered by the default validate_val(). resulting bytes instead of a hexstring length. Most subclasses will be covered by the default validate_val().
@@ -117,6 +128,8 @@ class ConfigurableParameter(abc.ABC, metaclass=ClassVarMeta):
max_len = None max_len = None
allow_len = None # a list of specific lengths allow_len = None # a list of specific lengths
example_input = None example_input = None
default_source = None # a param_source.ParamSource subclass
numeric_base = None # or 10 or 16
def __init__(self, input_value=None): def __init__(self, input_value=None):
self.input_value = input_value # the raw input value as given by caller self.input_value = input_value # the raw input value as given by caller
@@ -178,19 +191,28 @@ class ConfigurableParameter(abc.ABC, metaclass=ClassVarMeta):
if cls.allow_chars is not None: if cls.allow_chars is not None:
if any(c not in cls.allow_chars for c in val): if any(c not in cls.allow_chars for c in val):
raise ValueError(f"invalid characters in input value {val!r}, valid chars are {cls.allow_chars}") raise ValueError(f"invalid characters in input value {val!r}, valid chars are {cls.allow_chars}")
elif isinstance(val, io.BytesIO):
val = val.getvalue()
if hasattr(val, '__len__'):
val_len = len(val)
else:
# e.g. int length
val_len = len(str(val))
if cls.allow_len is not None: if cls.allow_len is not None:
l = cls.allow_len l = cls.allow_len
# cls.allow_len could be one int, or a tuple of ints. Wrap a single int also in a tuple. # cls.allow_len could be one int, or a tuple of ints. Wrap a single int also in a tuple.
if not isinstance(l, (tuple, list)): if not isinstance(l, (tuple, list)):
l = (l,) l = (l,)
if len(val) not in l: if val_len not in l:
raise ValueError(f'length must be one of {cls.allow_len}, not {len(val)}: {val!r}') raise ValueError(f'length must be one of {cls.allow_len}, not {val_len}: {val!r}')
if cls.min_len is not None: if cls.min_len is not None:
if len(val) < cls.min_len: if val_len < cls.min_len:
raise ValueError(f'length must be at least {cls.min_len}, not {len(val)}: {val!r}') raise ValueError(f'length must be at least {cls.min_len}, not {val_len}: {val!r}')
if cls.max_len is not None: if cls.max_len is not None:
if len(val) > cls.max_len: if val_len > cls.max_len:
raise ValueError(f'length must be at most {cls.max_len}, not {len(val)}: {val!r}') raise ValueError(f'length must be at most {cls.max_len}, not {val_len}: {val!r}')
return val return val
@classmethod @classmethod
@@ -199,6 +221,49 @@ class ConfigurableParameter(abc.ABC, metaclass=ClassVarMeta):
Write the given val in the right format in all the right places in pes.""" Write the given val in the right format in all the right places in pes."""
pass pass
@classmethod
def get_value_from_pes(cls, pes: ProfileElementSequence):
"""Same as get_values_from_pes() but expecting a single value.
get_values_from_pes() may return values like this:
[{ 'AlgorithmID': 'Milenage' }, { 'AlgorithmID': 'Milenage' }]
This ensures that all these entries are identical and would return only
{ 'AlgorithmID': 'Milenage' }.
This is relevant for any profile element that may appear multiple times in the same PES (only a few),
where each occurrence should reflect the same value (all currently known parameters).
"""
val = None
for v in cls.get_values_from_pes(pes):
if val is None:
val = v
elif val != v:
raise ValueError(f'get_value_from_pes(): got distinct values: {val!r} != {v!r}')
return val
@classmethod
@abc.abstractmethod
def get_values_from_pes(cls, pes: ProfileElementSequence) -> Generator:
"""This is what subclasses implement: yield all values from a decoded profile package.
Find all values in the pes, and yield them decoded to a valid cls.input_value format.
Should be a generator function, i.e. use 'yield' instead of 'return'.
Yielded value must be a dict(). Usually, an implementation will return only one key, like
{ "ICCID": "1234567890123456789" }
Some implementations have more than one value to return, like
{ "IMSI": "00101012345678", "IMSI-ACC" : "5" }
Implementation example:
for pe in pes:
if my_condition(pe):
yield { cls.name: b2h(my_bin_value_from(pe)) }
"""
pass
@classmethod @classmethod
def get_len_range(cls): def get_len_range(cls):
"""considering all of min_len, max_len and allow_len, get a tuple of the resulting (min, max) of permitted """considering all of min_len, max_len and allow_len, get a tuple of the resulting (min, max) of permitted
@@ -219,6 +284,20 @@ class ConfigurableParameter(abc.ABC, metaclass=ClassVarMeta):
return (None, None) return (None, None)
return (min(vals), max(vals)) return (min(vals), max(vals))
@classmethod
def get_typical_input_len(cls):
'''return a good length to use as the visible width of a user interface input field.
May be overridden by subclasses.
This default implementation returns the maximum allowed value length -- a good fit for most subclasses.
'''
return cls.get_len_range()[1] or 16
@classmethod
def is_super_of(cls, other_class):
try:
return issubclass(other_class, cls)
except TypeError:
return False
class DecimalParam(ConfigurableParameter): class DecimalParam(ConfigurableParameter):
"""Decimal digits. The input value may be a string of decimal digits like '012345', or an int. The output of """Decimal digits. The input value may be a string of decimal digits like '012345', or an int. The output of
@@ -226,6 +305,7 @@ class DecimalParam(ConfigurableParameter):
""" """
allow_types = (str, int) allow_types = (str, int)
allow_chars = '0123456789' allow_chars = '0123456789'
numeric_base = 10
@classmethod @classmethod
def validate_val(cls, val): def validate_val(cls, val):
@@ -249,6 +329,7 @@ class DecimalHexParam(DecimalParam):
@classmethod @classmethod
def validate_val(cls, val): def validate_val(cls, val):
val = super().validate_val(val) val = super().validate_val(val)
assert isinstance(val, str)
val = ''.join('%02x' % ord(x) for x in val) val = ''.join('%02x' % ord(x) for x in val)
if cls.rpad is not None: if cls.rpad is not None:
c = cls.rpad_char c = cls.rpad_char
@@ -256,9 +337,21 @@ class DecimalHexParam(DecimalParam):
# a DecimalHexParam subclass expects the apply_val() input to be a bytes instance ready for the pes # a DecimalHexParam subclass expects the apply_val() input to be a bytes instance ready for the pes
return h2b(val) return h2b(val)
@classmethod
def decimal_hex_to_str(cls, val):
"""useful for get_values_from_pes() implementations of subclasses"""
if isinstance(val, bytes):
val = b2h(val)
assert isinstance(val, hexstr)
if cls.rpad is not None:
c = cls.rpad_char or 'f'
val = unrpad(val, c)
return val.to_bytes().decode('ascii')
class IntegerParam(ConfigurableParameter): class IntegerParam(ConfigurableParameter):
allow_types = (str, int) allow_types = (str, int)
allow_chars = '0123456789' allow_chars = '0123456789'
numeric_base = 10
# two integers, if the resulting int should be range limited # two integers, if the resulting int should be range limited
min_val = None min_val = None
@@ -279,14 +372,28 @@ class IntegerParam(ConfigurableParameter):
raise ValueError(f'Value {val} is out of range, must be [{cls.min_val}..{cls.max_val}]') raise ValueError(f'Value {val} is out of range, must be [{cls.min_val}..{cls.max_val}]')
return val return val
@classmethod
def get_values_from_pes(cls, pes: ProfileElementSequence):
for valdict in super().get_values_from_pes(pes):
for key, val in valdict.items():
if isinstance(val, int):
valdict[key] = str(val)
yield valdict
class BinaryParam(ConfigurableParameter): class BinaryParam(ConfigurableParameter):
allow_types = (str, io.BytesIO, bytes, bytearray) allow_types = (str, io.BytesIO, bytes, bytearray, int)
allow_chars = '0123456789abcdefABCDEF' allow_chars = '0123456789abcdefABCDEF'
strip_chars = ' \t\r\n' strip_chars = ' \t\r\n'
numeric_base = 16
default_source = param_source.RandomHexDigitSource
@classmethod @classmethod
def validate_val(cls, val): def validate_val(cls, val):
# take care that min_len and max_len are applied to the binary length by converting to bytes first # take care that min_len and max_len are applied to the binary length by converting to bytes first
if isinstance(val, int):
min_len, _max_len = cls.get_len_range()
val = '%0*d' % (min_len, val)
if isinstance(val, str): if isinstance(val, str):
if cls.strip_chars is not None: if cls.strip_chars is not None:
val = ''.join(c for c in val if c not in cls.strip_chars) val = ''.join(c for c in val if c not in cls.strip_chars)
@@ -301,6 +408,82 @@ class BinaryParam(ConfigurableParameter):
val = super().validate_val(val) val = super().validate_val(val)
return bytes(val) return bytes(val)
@classmethod
def get_typical_input_len(cls):
# override to return twice the length, because of hex digits.
min_len, max_len = cls.get_len_range()
if max_len is None:
return None
# two hex characters per value octet.
# (maybe *3 to also allow for spaces?)
return max_len * 2
class EnumParam(ConfigurableParameter):
"""ConfigurableParameter for named integer enumeration values.
Subclasses must define a nested enum.IntEnum named 'Values' listing all valid names and their
integer codes. apply_val() and get_values_from_pes() are not implemented here and this must
be inherited from another mixin."""
class Values(enum.IntEnum):
pass # subclasses override this
@classmethod
def validate_val(cls, val) -> int:
if isinstance(val, int):
try:
return int(cls.Values(val))
except ValueError:
pass
elif isinstance(val, str):
member = cls.map_name_to_val(val, strict=False)
if member is not None:
return member
valid = ', '.join(m.name for m in cls.Values)
raise ValueError(f"{cls.get_name()}: invalid argument: {val!r}. Valid arguments are: {valid}")
@classmethod
def map_name_to_val(cls, name: str, strict=True) -> int:
"""Return the integer value for a given enum member name. Performs an exact match first,
then falls back to fuzzy matching (case-insensitive, punctuation-insensitive)."""
try:
return int(cls.Values[name])
except KeyError:
pass
clean = cls.clean_name_str(name)
for member in cls.Values:
if cls.clean_name_str(member.name) == clean:
return int(member)
if strict:
valid = ', '.join(m.name for m in cls.Values)
raise ValueError(f"{cls.get_name()}: {name!r} is not a known value. Known values are: {valid}")
return None
@classmethod
def map_val_to_name(cls, val, strict=False) -> str:
"""Return the enum member name for a given integer value."""
try:
return cls.Values(val).name
except ValueError:
if strict:
raise ValueError(f"{cls.get_name()}: {val!r} ({type(val).__name__}) is not a known value.")
return None
@classmethod
def name_normalize(cls, name: str) -> str:
"""Map a (possibly fuzzy) name to its canonical enum member name."""
return cls.Values(cls.map_name_to_val(name)).name
@classmethod
def clean_name_str(cls, val: str) -> str:
"""Strip punctuation and case for fuzzy name comparison.
Treats hyphens and underscores as equivalent (both removed)."""
return re.sub('[^0-9A-Za-z]', '', val).lower()
class Iccid(DecimalParam): class Iccid(DecimalParam):
"""ICCID Parameter. Input: string of decimal digits. """ICCID Parameter. Input: string of decimal digits.
@@ -309,6 +492,7 @@ class Iccid(DecimalParam):
min_len = 18 min_len = 18
max_len = 20 max_len = 20
example_input = '998877665544332211' example_input = '998877665544332211'
default_source = param_source.IncDigitSource
@classmethod @classmethod
def validate_val(cls, val): def validate_val(cls, val):
@@ -322,6 +506,17 @@ class Iccid(DecimalParam):
# patch MF/EF.ICCID # patch MF/EF.ICCID
file_replace_content(pes.get_pe_for_type('mf').decoded['ef-iccid'], h2b(enc_iccid(val))) file_replace_content(pes.get_pe_for_type('mf').decoded['ef-iccid'], h2b(enc_iccid(val)))
@classmethod
def get_values_from_pes(cls, pes: ProfileElementSequence):
padded = b2h(pes.get_pe_for_type('header').decoded['iccid'])
iccid = unrpad(padded)
yield { cls.name: iccid }
for pe in pes.get_pes_for_type('mf'):
iccid_f = pe.files.get('ef-iccid', None)
if iccid_f is not None:
yield { cls.name: dec_iccid(b2h(iccid_f.body)) }
class Imsi(DecimalParam): class Imsi(DecimalParam):
"""Configurable IMSI. Expects value to be a string of digits. Automatically sets the ACC to """Configurable IMSI. Expects value to be a string of digits. Automatically sets the ACC to
the last digit of the IMSI.""" the last digit of the IMSI."""
@@ -330,6 +525,7 @@ class Imsi(DecimalParam):
min_len = 6 min_len = 6
max_len = 15 max_len = 15
example_input = '00101' + ('0' * 10) example_input = '00101' + ('0' * 10)
default_source = param_source.IncDigitSource
@classmethod @classmethod
def apply_val(cls, pes: ProfileElementSequence, val): def apply_val(cls, pes: ProfileElementSequence, val):
@@ -342,6 +538,18 @@ class Imsi(DecimalParam):
file_replace_content(pe.decoded['ef-acc'], acc.to_bytes(2, 'big')) file_replace_content(pe.decoded['ef-acc'], acc.to_bytes(2, 'big'))
# TODO: DF.GSM_ACCESS if not linked? # TODO: DF.GSM_ACCESS if not linked?
@classmethod
def get_values_from_pes(cls, pes: ProfileElementSequence):
for pe in pes.get_pes_for_type('usim'):
imsi_f = pe.files.get('ef-imsi', None)
acc_f = pe.files.get('ef-acc', None)
y = {}
if imsi_f:
y[cls.name] = dec_imsi(b2h(imsi_f.body))
if acc_f:
y[cls.name + '-ACC'] = b2h(acc_f.body)
yield y
class SmspTpScAddr(ConfigurableParameter): class SmspTpScAddr(ConfigurableParameter):
"""Configurable SMSC (SMS Service Centre) TP-SC-ADDR. Expects to be a phone number in national or """Configurable SMSC (SMS Service Centre) TP-SC-ADDR. Expects to be a phone number in national or
international format (designated by a leading +). Automatically sets the NPI to E.164 and the TON based on international format (designated by a leading +). Automatically sets the NPI to E.164 and the TON based on
@@ -350,25 +558,45 @@ class SmspTpScAddr(ConfigurableParameter):
name = 'SMSP-TP-SC-ADDR' name = 'SMSP-TP-SC-ADDR'
allow_chars = '+0123456789' allow_chars = '+0123456789'
strip_chars = ' \t\r\n' strip_chars = ' \t\r\n'
numeric_base = 10
max_len = 21 # '+' and 20 digits max_len = 21 # '+' and 20 digits
min_len = 1 min_len = 1
example_input = '+49301234567' example_input = '+49301234567'
default_source = param_source.ConstantSource
@classmethod @staticmethod
def validate_val(cls, val): def str_to_tuple(addr_str):
val = super().validate_val(val)
addr_str = str(val)
if addr_str[0] == '+': if addr_str[0] == '+':
digits = addr_str[1:] digits = addr_str[1:]
international = True international = True
else: else:
digits = addr_str digits = addr_str
international = False international = False
return (international, digits)
@staticmethod
def tuple_to_str(addr_tuple):
international, digits = addr_tuple
if international:
ret = '+'
else:
ret = ''
ret += digits
return ret
@classmethod
def validate_val(cls, val):
val = super().validate_val(val)
addr_tuple = cls.str_to_tuple(str(val))
international, digits = addr_tuple
if len(digits) > 20: if len(digits) > 20:
raise ValueError(f'TP-SC-ADDR must not exceed 20 digits: {digits!r}') raise ValueError(f'TP-SC-ADDR must not exceed 20 digits: {digits!r}')
if not digits.isdecimal(): if not digits.isdecimal():
raise ValueError(f'TP-SC-ADDR must only contain decimal digits: {digits!r}') raise ValueError(f'TP-SC-ADDR must only contain decimal digits: {digits!r}')
return (international, digits)
return addr_tuple
@classmethod @classmethod
def apply_val(cls, pes: ProfileElementSequence, val): def apply_val(cls, pes: ProfileElementSequence, val):
@@ -392,98 +620,318 @@ class SmspTpScAddr(ConfigurableParameter):
ef_smsp_dec['tp_sc_addr']['ton_npi']['type_of_number'] = 'international' if international else 'unknown' ef_smsp_dec['tp_sc_addr']['ton_npi']['type_of_number'] = 'international' if international else 'unknown'
# ensure the parameter_indicators.tp_sc_addr is True # ensure the parameter_indicators.tp_sc_addr is True
ef_smsp_dec['parameter_indicators']['tp_sc_addr'] = True ef_smsp_dec['parameter_indicators']['tp_sc_addr'] = True
# re-encode into the File body
f_smsp.body = ef_smsp.encode_record_bin(ef_smsp_dec, 1) # alpha_id padding: to make room for a human readable SMSC name that can be provisioned to the profile later
# on, alpha_id needs to be empty but padded 0xff to some length.
# - alpha_id is optional, setting alpha_id = '' ensures the IE is present.
# - the length of the file is 28+Y where Y is the length of the alpha_id -- here the intended length of our padding
# (see 3GPP TS 31.102 4.2.27 EF.SMSP). So if we want a maximum length of alpha_id = 14, we set the total
# file size to 28+14 = 42.
# - this file size has to go in two places: encode_record_bin() needs to know the length to encode the right
# length of fillFileContent.
# - the f_smsp needs to show the right file size in the PES, as in
# 'ef-smsp': [('fileDescriptor', {'efFileSize': '2a', ...
# (where 2a == 42)
# - To generate the right amount of fillFileContent, pass total_len=42 to encode_record_bin().
# - To show the right size in the PES, set f_smsp.rec_len = 42
ef_smsp_dec['alpha_id'] = ''
f_smsp.rec_len = 42
# re-encode into the File body.
#
#print("SMSP (new): %s" % f_smsp.body) #print("SMSP (new): %s" % f_smsp.body)
# re-generate the pe.decoded member from the File instance # re-generate the pe.decoded member from the File instance
f_smsp.body = ef_smsp.encode_record_bin(ef_smsp_dec, 1, total_len=f_smsp.rec_len)
pe.file2pe(f_smsp) pe.file2pe(f_smsp)
class SdKey(BinaryParam, metaclass=ClassVarMeta): @classmethod
"""Configurable Security Domain (SD) Key. Value is presented as bytes.""" def get_values_from_pes(cls, pes: ProfileElementSequence):
for pe in pes.get_pes_for_type('usim'):
f_smsp = pe.files['ef-smsp']
ef_smsp = EF_SMSP()
ef_smsp_dec = ef_smsp.decode_record_bin(f_smsp.body, 1)
tp_sc_addr = ef_smsp_dec.get('tp_sc_addr', None)
digits = tp_sc_addr.get('call_number', None)
ton_npi = tp_sc_addr.get('ton_npi', None)
international = ton_npi.get('type_of_number', None)
international = (international == 'international')
yield { cls.name: cls.tuple_to_str((international, digits)) }
class MncLen(EnumParam):
"""MNC length. Sets only the MNC length field in EF.AD (Administrative Data).
Accepted values: integer 2 or 3, digit strings '2' or '3', or enum names 'MNC2'/'MNC3'.
"""
name = 'MNC-LEN'
example_input = '2'
default_source = param_source.ConstantSource
class Values(enum.IntEnum):
MNC2 = 2
MNC3 = 3
@classmethod
def validate_val(cls, val):
if isinstance(val, str) and val.isdigit():
val = int(val)
return super().validate_val(val)
@classmethod
def _get_f_ad(cls, pe: ProfileElement):
if not hasattr(pe, 'files'):
return None
f_ad = pe.files.get('ef-ad', None)
if f_ad and f_ad.body:
return f_ad
return None
@classmethod
def _decode_f_ad(cls, f_ad):
try:
ef_ad_dec = EF_AD().decode_bin(f_ad.body)
except StreamError:
return None
if 'mnc_len' not in ef_ad_dec:
return None
return ef_ad_dec
@classmethod
def apply_val(cls, pes: ProfileElementSequence, val: int):
for pe in pes.get_pes_for_type('usim'):
f_ad = cls._get_f_ad(pe)
if f_ad is None:
continue
# decode existing values
ef_ad_dec = cls._decode_f_ad(f_ad)
if ef_ad_dec is None:
continue
# change mnc_len
ef_ad_dec['mnc_len'] = val
# re-encode into the File body
f_ad.body = EF_AD().encode_bin(ef_ad_dec)
pe.file2pe(f_ad)
@classmethod
def get_values_from_pes(cls, pes: ProfileElementSequence):
for pe in pes.get_pes_for_type('usim'):
f_ad = cls._get_f_ad(pe)
if f_ad is None:
continue
ef_ad_dec = cls._decode_f_ad(f_ad)
if ef_ad_dec is None:
continue
mnc_len = ef_ad_dec.get('mnc_len')
yield { cls.name: str(mnc_len) }
class SdKey(BinaryParam):
"""Configurable Security Domain (SD) Key. Value is presented as bytes.
Non-abstract implementations are generated in SdKey.generate_sd_key_classes"""
# these will be set by subclasses # these will be set by subclasses
key_type = None key_type = None
key_id = None
kvn = None kvn = None
key_id = None
key_usage_qual = None key_usage_qual = None
@classmethod @classmethod
def _apply_sd(cls, pe: ProfileElement, value): def apply_val(cls, pes: ProfileElementSequence, val):
assert pe.type == 'securityDomain' set_components = [ SecurityDomainKeyComponent(cls.key_type, val) ]
for key in pe.decoded['keyList']:
if key['keyIdentifier'][0] == cls.key_id and key['keyVersionNumber'][0] == cls.kvn: for pe in pes.pe_list:
assert len(key['keyComponents']) == 1 if pe.type != 'securityDomain':
key['keyComponents'][0]['keyData'] = value continue
return assert isinstance(pe, ProfileElementSD)
# Could not find matching key to patch, create a new one
key = { key = pe.find_key(key_version_number=cls.kvn, key_id=cls.key_id)
'keyUsageQualifier': bytes([cls.key_usage_qual]), if not key:
'keyIdentifier': bytes([cls.key_id]), # Could not find matching key to patch, create a new one
'keyVersionNumber': bytes([cls.kvn]), key = SecurityDomainKey(
'keyComponents': [ key_version_number=cls.kvn,
{ 'keyType': bytes([cls.key_type]), 'keyData': value }, key_id=cls.key_id,
] key_usage_qualifier=cls.key_usage_qual,
} key_components=set_components,
pe.decoded['keyList'].append(key) )
pe.add_key(key)
else:
# A key of this KVN and ID already exists in the profile.
# Keep the key_usage_qualifier as it was in the profile, so skip this here:
# key.key_usage_qualifier = cls.key_usage_qual
key.key_components = set_components
@classmethod @classmethod
def apply_val(cls, pes: ProfileElementSequence, value): def get_values_from_pes(cls, pes: ProfileElementSequence):
for pe in pes.get_pes_for_type('securityDomain'): for pe in pes.pe_list:
cls._apply_sd(pe, value) if pe.type != 'securityDomain':
continue
assert isinstance(pe, ProfileElementSD)
class SdKeyScp80_01(SdKey, kvn=0x01, key_type=0x88, permitted_len=[16,24,32]): # AES key type key = pe.find_key(key_version_number=cls.kvn, key_id=cls.key_id)
pass if not key:
class SdKeyScp80_01Kic(SdKeyScp80_01, key_id=0x01, key_usage_qual=0x18): # FIXME: ordering? continue
pass kc = key.get_key_component(cls.key_type)
class SdKeyScp80_01Kid(SdKeyScp80_01, key_id=0x02, key_usage_qual=0x14): if kc:
pass yield { cls.name: b2h(kc) }
class SdKeyScp80_01Kik(SdKeyScp80_01, key_id=0x03, key_usage_qual=0x48):
pass
class SdKeyScp81_01(SdKey, kvn=0x81): # FIXME
pass
class SdKeyScp81_01Psk(SdKeyScp81_01, key_id=0x01, key_type=0x85, key_usage_qual=0x3C):
pass
class SdKeyScp81_01Dek(SdKeyScp81_01, key_id=0x02, key_type=0x88, key_usage_qual=0x48):
pass
class SdKeyScp02_20(SdKey, kvn=0x20, key_type=0x88, permitted_len=[16,24,32]): # AES key type
pass
class SdKeyScp02_20Enc(SdKeyScp02_20, key_id=0x01, key_usage_qual=0x18):
pass
class SdKeyScp02_20Mac(SdKeyScp02_20, key_id=0x02, key_usage_qual=0x14):
pass
class SdKeyScp02_20Dek(SdKeyScp02_20, key_id=0x03, key_usage_qual=0x48):
pass
class SdKeyScp03_30(SdKey, kvn=0x30, key_type=0x88, permitted_len=[16,24,32]): # AES key type
pass
class SdKeyScp03_30Enc(SdKeyScp03_30, key_id=0x01, key_usage_qual=0x18):
pass
class SdKeyScp03_30Mac(SdKeyScp03_30, key_id=0x02, key_usage_qual=0x14):
pass
class SdKeyScp03_30Dek(SdKeyScp03_30, key_id=0x03, key_usage_qual=0x48):
pass
class SdKeyScp03_31(SdKey, kvn=0x31, key_type=0x88, permitted_len=[16,24,32]): # AES key type
pass
class SdKeyScp03_31Enc(SdKeyScp03_31, key_id=0x01, key_usage_qual=0x18):
pass
class SdKeyScp03_31Mac(SdKeyScp03_31, key_id=0x02, key_usage_qual=0x14):
pass
class SdKeyScp03_31Dek(SdKeyScp03_31, key_id=0x03, key_usage_qual=0x48):
pass
class SdKeyScp03_32(SdKey, kvn=0x32, key_type=0x88, permitted_len=[16,24,32]): # AES key type
pass
class SdKeyScp03_32Enc(SdKeyScp03_32, key_id=0x01, key_usage_qual=0x18):
pass
class SdKeyScp03_32Mac(SdKeyScp03_32, key_id=0x02, key_usage_qual=0x14):
pass
class SdKeyScp03_32Dek(SdKeyScp03_32, key_id=0x03, key_usage_qual=0x48):
pass
LEN_128 = (16,)
LEN_128_192_256 = (16, 24, 32)
LEN_128_256 = (16, 32)
DES = ('DES', dict(key_type=KeyType.des, allow_len=LEN_128) )
AES = ('AES', dict(key_type=KeyType.aes, allow_len=LEN_128_192_256) )
ENC = ('ENC', dict(key_id=0x01, key_usage_qual=0x18) )
MAC = ('MAC', dict(key_id=0x02, key_usage_qual=0x14) )
DEK = ('DEK', dict(key_id=0x03, key_usage_qual=0x48) )
TLSPSK_PSK = ('TLSPSK', dict(key_type=KeyType.tls_psk, key_id=0x01, key_usage_qual=0x3c, allow_len=LEN_128_192_256) )
TLSPSK_DEK = ('DEK', dict(key_id=0x02, key_usage_qual=0x48) )
# THIS IS THE LIST that controls which SdKeyXxx subclasses exist:
SD_KEY_DEFS = (
# name KVN x variants x variants
('SCP02', (0x20, 0x21, 0x22, 0xff), (AES, ), (ENC, MAC, DEK) ),
('SCP03', (0x30, 0x31, 0x32), (AES, ), (ENC, MAC, DEK) ),
('SCP80', (0x01, 0x02, 0x03), (DES, AES), (ENC, MAC, DEK) ),
# key_id=1
('SCP81', (0x40, 0x41, 0x42), (TLSPSK_PSK, ), ),
# key_id=2
('SCP81', (0x40, 0x41, 0x42), (DES, AES), (TLSPSK_DEK, ) ),
)
all_implementations = None
@classmethod
def generate_sd_key_classes(cls, sd_key_defs=SD_KEY_DEFS):
'''This generates python classes to be exported in this module, as subclasses of class SdKey.
We create SdKey subclasses dynamically from a list.
You can list all of them via:
from pySim.esim.saip.personalization import SdKey
SdKey.all_implementations
or
print('\n'.join(sorted(f'{x.__name__}\t{x.name}' for x in SdKey.all_implementations)))
at time of writing this comment, this prints:
SdKeyScp02Kvn20AesDek SCP02-KVN20-AES-DEK
SdKeyScp02Kvn20AesEnc SCP02-KVN20-AES-ENC
SdKeyScp02Kvn20AesMac SCP02-KVN20-AES-MAC
SdKeyScp02Kvn21AesDek SCP02-KVN21-AES-DEK
SdKeyScp02Kvn21AesEnc SCP02-KVN21-AES-ENC
SdKeyScp02Kvn21AesMac SCP02-KVN21-AES-MAC
SdKeyScp02Kvn22AesDek SCP02-KVN22-AES-DEK
SdKeyScp02Kvn22AesEnc SCP02-KVN22-AES-ENC
SdKeyScp02Kvn22AesMac SCP02-KVN22-AES-MAC
SdKeyScp02KvnffAesDek SCP02-KVNff-AES-DEK
SdKeyScp02KvnffAesEnc SCP02-KVNff-AES-ENC
SdKeyScp02KvnffAesMac SCP02-KVNff-AES-MAC
SdKeyScp03Kvn30AesDek SCP03-KVN30-AES-DEK
SdKeyScp03Kvn30AesEnc SCP03-KVN30-AES-ENC
SdKeyScp03Kvn30AesMac SCP03-KVN30-AES-MAC
SdKeyScp03Kvn31AesDek SCP03-KVN31-AES-DEK
SdKeyScp03Kvn31AesEnc SCP03-KVN31-AES-ENC
SdKeyScp03Kvn31AesMac SCP03-KVN31-AES-MAC
SdKeyScp03Kvn32AesDek SCP03-KVN32-AES-DEK
SdKeyScp03Kvn32AesEnc SCP03-KVN32-AES-ENC
SdKeyScp03Kvn32AesMac SCP03-KVN32-AES-MAC
SdKeyScp80Kvn01AesDek SCP80-KVN01-AES-DEK
SdKeyScp80Kvn01AesEnc SCP80-KVN01-AES-ENC
SdKeyScp80Kvn01AesMac SCP80-KVN01-AES-MAC
SdKeyScp80Kvn01DesDek SCP80-KVN01-DES-DEK
SdKeyScp80Kvn01DesEnc SCP80-KVN01-DES-ENC
SdKeyScp80Kvn01DesMac SCP80-KVN01-DES-MAC
SdKeyScp80Kvn02AesDek SCP80-KVN02-AES-DEK
SdKeyScp80Kvn02AesEnc SCP80-KVN02-AES-ENC
SdKeyScp80Kvn02AesMac SCP80-KVN02-AES-MAC
SdKeyScp80Kvn02DesDek SCP80-KVN02-DES-DEK
SdKeyScp80Kvn02DesEnc SCP80-KVN02-DES-ENC
SdKeyScp80Kvn02DesMac SCP80-KVN02-DES-MAC
SdKeyScp80Kvn03AesDek SCP80-KVN03-AES-DEK
SdKeyScp80Kvn03AesEnc SCP80-KVN03-AES-ENC
SdKeyScp80Kvn03AesMac SCP80-KVN03-AES-MAC
SdKeyScp80Kvn03DesDek SCP80-KVN03-DES-DEK
SdKeyScp80Kvn03DesEnc SCP80-KVN03-DES-ENC
SdKeyScp80Kvn03DesMac SCP80-KVN03-DES-MAC
SdKeyScp81Kvn40AesDek SCP81-KVN40-AES-DEK
SdKeyScp81Kvn40DesDek SCP81-KVN40-DES-DEK
SdKeyScp81Kvn40Tlspsk SCP81-KVN40-TLSPSK
SdKeyScp81Kvn41AesDek SCP81-KVN41-AES-DEK
SdKeyScp81Kvn41DesDek SCP81-KVN41-DES-DEK
SdKeyScp81Kvn41Tlspsk SCP81-KVN41-TLSPSK
SdKeyScp81Kvn42AesDek SCP81-KVN42-AES-DEK
SdKeyScp81Kvn42DesDek SCP81-KVN42-DES-DEK
SdKeyScp81Kvn42Tlspsk SCP81-KVN42-TLSPSK
'''
SdKey.all_implementations = []
def camel(s):
return s[:1].upper() + s[1:].lower()
def do_variants(name, kvn, remaining_variants, labels=[], attrs={}):
'recurse to unfold as many variants as there may be'
if remaining_variants:
# not a leaf node, collect more labels and attrs
variants = remaining_variants[0]
remaining_variants = remaining_variants[1:]
for label, valdict in variants:
# pass copies to recursion
inner_labels = list(labels)
inner_attrs = dict(attrs)
inner_labels.append(label)
inner_attrs.update(valdict)
do_variants(name, kvn, remaining_variants,
labels=inner_labels,
attrs=inner_attrs)
return
# leaf node. create a new class with all the accumulated vals
parts = [name, f'KVN{kvn:02x}',] + labels
cls_label = '-'.join(p for p in parts if p)
parts = ['Sd', 'Key', name, f'Kvn{kvn:02x}'] + labels
clsname = ''.join(camel(p) for p in parts)
max_key_len = attrs.get('allow_len')[-1]
attrs.update({
'name' : cls_label,
'kvn': kvn,
'example_input': f'00*{max_key_len}',
})
# below line is like
# class SdKeyScpNNKvnXXYyyZzz(SdKey):
# <set attrs>
cls_def = type(clsname, (cls,), attrs)
# for some unknown reason, subclassing from abc.ABC makes cls_def.__module__ == 'abc',
# but we don't want 'abc.SdKeyScp03Kvn32AesEnc'.
# Make sure it is 'pySim.esim.saip.personalization.SdKeyScp03Kvn32AesEnc'
cls_def.__module__ = __name__
globals()[clsname] = cls_def
SdKey.all_implementations.append(cls_def)
for items in sd_key_defs:
name, kvns = items[:2]
variants = items[2:]
for kvn in kvns:
do_variants(name, kvn, variants)
# this creates all of the classes named like SdKeyScp02Kvn20AesDek to be published in this python module:
SdKey.generate_sd_key_classes()
def obtain_all_pe_from_pelist(l: List[ProfileElement], wanted_type: str) -> ProfileElement: def obtain_all_pe_from_pelist(l: List[ProfileElement], wanted_type: str) -> ProfileElement:
return (pe for pe in l if pe.type == wanted_type) return (pe for pe in l if pe.type == wanted_type)
@@ -502,7 +950,8 @@ class Puk(DecimalHexParam):
allow_len = 8 allow_len = 8
rpad = 16 rpad = 16
keyReference = None keyReference = None
example_input = '0' * allow_len example_input = f'0*{allow_len}'
default_source = param_source.RandomDigitSource
@classmethod @classmethod
def apply_val(cls, pes: ProfileElementSequence, val): def apply_val(cls, pes: ProfileElementSequence, val):
@@ -516,6 +965,14 @@ class Puk(DecimalHexParam):
raise ValueError("input template UPP has unexpected structure:" raise ValueError("input template UPP has unexpected structure:"
f" cannot find pukCode with keyReference={cls.keyReference}") f" cannot find pukCode with keyReference={cls.keyReference}")
@classmethod
def get_values_from_pes(cls, pes: ProfileElementSequence):
mf_pes = pes.pes_by_naa['mf'][0]
for pukCodes in obtain_all_pe_from_pelist(mf_pes, 'pukCodes'):
for pukCode in pukCodes.decoded['pukCodes']:
if pukCode['keyReference'] == cls.keyReference:
yield { cls.name: cls.decimal_hex_to_str(pukCode['pukValue']) }
class Puk1(Puk): class Puk1(Puk):
name = 'PUK1' name = 'PUK1'
keyReference = 0x01 keyReference = 0x01
@@ -529,7 +986,8 @@ class Pin(DecimalHexParam):
rpad = 16 rpad = 16
min_len = 4 min_len = 4
max_len = 8 max_len = 8
example_input = '0' * max_len example_input = f'0*{max_len}'
default_source = param_source.RandomDigitSource
keyReference = None keyReference = None
@staticmethod @staticmethod
@@ -551,9 +1009,24 @@ class Pin(DecimalHexParam):
raise ValueError('input template UPP has unexpected structure:' raise ValueError('input template UPP has unexpected structure:'
+ f' {cls.get_name()} cannot find pinCode with keyReference={cls.keyReference}') + f' {cls.get_name()} cannot find pinCode with keyReference={cls.keyReference}')
@classmethod
def _read_all_pinvalues_from_pe(cls, pe: ProfileElement):
"This is a separate function because subclasses may feed different pe arguments."
for pinCodes in obtain_all_pe_from_pelist(pe, 'pinCodes'):
if pinCodes.decoded['pinCodes'][0] != 'pinconfig':
continue
for pinCode in pinCodes.decoded['pinCodes'][1]:
if pinCode['keyReference'] == cls.keyReference:
yield { cls.name: cls.decimal_hex_to_str(pinCode['pinValue']) }
@classmethod
def get_values_from_pes(cls, pes: ProfileElementSequence):
yield from cls._read_all_pinvalues_from_pe(pes.pes_by_naa['mf'][0])
class Pin1(Pin): class Pin1(Pin):
name = 'PIN1' name = 'PIN1'
example_input = '0' * 4 # PIN are usually 4 digits example_input = '0*4' # PIN are usually 4 digits
keyReference = 0x01 keyReference = 0x01
class Pin2(Pin1): class Pin2(Pin1):
@@ -572,6 +1045,14 @@ class Pin2(Pin1):
raise ValueError('input template UPP has unexpected structure:' raise ValueError('input template UPP has unexpected structure:'
+ f' {cls.get_name()} cannot find pinCode with keyReference={cls.keyReference} in {naa=}') + f' {cls.get_name()} cannot find pinCode with keyReference={cls.keyReference} in {naa=}')
@classmethod
def get_values_from_pes(cls, pes: ProfileElementSequence):
for naa in pes.pes_by_naa:
if naa not in ['usim','isim','csim','telecom']:
continue
for pe in pes.pes_by_naa[naa]:
yield from cls._read_all_pinvalues_from_pe(pe)
class Adm1(Pin): class Adm1(Pin):
name = 'ADM1' name = 'ADM1'
keyReference = 0x0A keyReference = 0x0A
@@ -596,26 +1077,59 @@ class AlgoConfig(ConfigurableParameter):
raise ValueError('input template UPP has unexpected structure:' raise ValueError('input template UPP has unexpected structure:'
f' {cls.__name__} cannot find algoParameter with key={cls.algo_config_key}') f' {cls.__name__} cannot find algoParameter with key={cls.algo_config_key}')
class AlgorithmID(DecimalParam, AlgoConfig): @classmethod
def get_values_from_pes(cls, pes: ProfileElementSequence):
for pe in pes.get_pes_for_type('akaParameter'):
algoConfiguration = pe.decoded['algoConfiguration']
if len(algoConfiguration) < 2:
continue
if algoConfiguration[0] != 'algoParameter':
continue
if not algoConfiguration[1]:
continue
val = algoConfiguration[1].get(cls.algo_config_key, None)
if val is None:
continue
if isinstance(val, bytes):
val = b2h(val)
# if it is an int (algorithmID), just pass thru as int
yield { cls.name: val }
class AlgorithmID(EnumParam, AlgoConfig):
"""use validate_val() from EnumParam, and apply_val() from AlgoConfig.
In get_values_from_pes(), return enum value names, not raw values."""
name = "Algorithm"
algo_config_key = 'algorithmID' algo_config_key = 'algorithmID'
allow_len = 1 example_input = "Milenage"
example_input = 1 # Milenage default_source = param_source.ConstantSource
# as in pySim/esim/asn1/saip/PE_Definitions-3.3.1.asn
class Values(enum.IntEnum):
Milenage = 1
TUAK = 2
usim_test = 3 # input 'usim-test' also accepted via fuzzy matching
# EnumParam.validate_val() returns the int values from Values
@classmethod @classmethod
def validate_val(cls, val): def get_values_from_pes(cls, pes: ProfileElementSequence):
val = super().validate_val(val) # return enum names, not raw values.
val = int(val) # use of super(): this intends to call AlgoConfig.get_values_from_pes() so that the cls argument is this cls
valid = (1, 2, 3) # here (AlgorithmID); i.e. AlgoConfig.get_values_from_pes(pes) doesn't work, because AlgoConfig needs to look up
if val not in valid: # cls.algo_config_key.
raise ValueError(f'Invalid algorithmID {val!r}, must be one of {valid}') for d in super(cls, cls).get_values_from_pes(pes):
return val if cls.name in d:
# convert int to value string
val = d[cls.name]
d[cls.name] = cls.map_val_to_name(val, strict=True)
yield d
class K(BinaryParam, AlgoConfig): class K(BinaryParam, AlgoConfig):
"""use validate_val() from BinaryParam, and apply_val() from AlgoConfig""" """use validate_val() from BinaryParam, and apply_val() from AlgoConfig"""
name = 'K' name = 'K'
algo_config_key = 'key' algo_config_key = 'key'
allow_len = (128 // 8, 256 // 8) # length in bytes (from BinaryParam); TUAK also allows 256 bit allow_len = (128 // 8, 256 // 8) # length in bytes (from BinaryParam); TUAK also allows 256 bit
example_input = '00' * allow_len[0] example_input = f'00*{allow_len[0]}'
class Opc(K): class Opc(K):
name = 'OPc' name = 'OPc'
@@ -629,6 +1143,7 @@ class MilenageRotationConstants(BinaryParam, AlgoConfig):
algo_config_key = 'rotationConstants' algo_config_key = 'rotationConstants'
allow_len = 5 # length in bytes (from BinaryParam) allow_len = 5 # length in bytes (from BinaryParam)
example_input = '40 00 20 40 60' example_input = '40 00 20 40 60'
default_source = param_source.ConstantSource
@classmethod @classmethod
def validate_val(cls, val): def validate_val(cls, val):
@@ -641,7 +1156,7 @@ class MilenageRotationConstants(BinaryParam, AlgoConfig):
class MilenageXoringConstants(BinaryParam, AlgoConfig): class MilenageXoringConstants(BinaryParam, AlgoConfig):
"""XOR-ing constants c1,c2,c3,c4,c5 of Milenage, 128bit each. See 3GPP TS 35.206 Sections 2.3 + 5.3. """XOR-ing constants c1,c2,c3,c4,c5 of Milenage, 128bit each. See 3GPP TS 35.206 Sections 2.3 + 5.3.
Provided as octet-string concatenation of all 5 constants. The default value by 3GPP is the concetenation Provided as octet-string concatenation of all 5 constants. The default value by 3GPP is the concatenation
of:: of::
00000000000000000000000000000000 00000000000000000000000000000000
@@ -659,6 +1174,7 @@ class MilenageXoringConstants(BinaryParam, AlgoConfig):
' 00000000000000000000000000000002' ' 00000000000000000000000000000002'
' 00000000000000000000000000000004' ' 00000000000000000000000000000004'
' 00000000000000000000000000000008') ' 00000000000000000000000000000008')
default_source = param_source.ConstantSource
class TuakNumberOfKeccak(IntegerParam, AlgoConfig): class TuakNumberOfKeccak(IntegerParam, AlgoConfig):
"""Number of iterations of Keccak-f[1600] permutation as recomended by Section 7.2 of 3GPP TS 35.231""" """Number of iterations of Keccak-f[1600] permutation as recomended by Section 7.2 of 3GPP TS 35.231"""
@@ -667,3 +1183,4 @@ class TuakNumberOfKeccak(IntegerParam, AlgoConfig):
min_val = 1 min_val = 1
max_val = 255 max_val = 255
example_input = '1' example_input = '1'
default_source = param_source.ConstantSource
+41 -3
View File
@@ -226,9 +226,28 @@ class Icon(BER_TLV_IE, tag=0x94):
_construct = GreedyBytes _construct = GreedyBytes
class ProfileClass(BER_TLV_IE, tag=0x95): class ProfileClass(BER_TLV_IE, tag=0x95):
_construct = Enum(Int8ub, test=0, provisioning=1, operational=2) _construct = Enum(Int8ub, test=0, provisioning=1, operational=2)
class ProfilePolicyRules(BER_TLV_IE, tag=0x99):
_construct = GreedyBytes
class NotificationConfigurationInfo(BER_TLV_IE, tag=0xb6):
_construct = GreedyBytes
# ProfileOwner
class ProfileOwnerPLMN(BER_TLV_IE, tag=0x80):
_construct = PlmnAdapter(Bytes(3))
class ProfileOwnerGID1(BER_TLV_IE, tag=0x81):
_construct = GreedyBytes
class ProfileOwnerGID2(BER_TLV_IE, tag=0x82):
_construct = GreedyBytes
class ProfileOwner(BER_TLV_IE, tag=0xb7, nested=[ProfileOwnerPLMN, ProfileOwnerGID1, ProfileOwnerGID2]):
_construct = GreedyBytes
class SMDPPProprietaryData(BER_TLV_IE, tag=0xb8):
_construct = GreedyBytes
class ProfileInfo(BER_TLV_IE, tag=0xe3, nested=[Iccid, IsdpAid, ProfileState, ProfileNickname, class ProfileInfo(BER_TLV_IE, tag=0xe3, nested=[Iccid, IsdpAid, ProfileState, ProfileNickname,
ServiceProviderName, ProfileName, IconType, Icon, ServiceProviderName, ProfileName, IconType, Icon,
ProfileClass]): # FIXME: more IEs ProfileClass, ProfilePolicyRules, NotificationConfigurationInfo,
ProfileOwner, SMDPPProprietaryData]):
pass pass
class ProfileInfoSeq(BER_TLV_IE, tag=0xa0, nested=[ProfileInfo]): class ProfileInfoSeq(BER_TLV_IE, tag=0xa0, nested=[ProfileInfo]):
pass pass
@@ -444,9 +463,28 @@ class CardApplicationISDR(pySim.global_platform.CardApplicationSD):
d = rn.to_dict() d = rn.to_dict()
self._cmd.poutput_json(flatten_dict_lists(d['notification_sent_resp'])) self._cmd.poutput_json(flatten_dict_lists(d['notification_sent_resp']))
def do_get_profiles_info(self, _opts): get_profiles_info_parser = argparse.ArgumentParser()
get_profiles_info_parser.add_argument('--all', action='store_true', help='Retrieve all known tags of a profile')
@cmd2.with_argparser(get_profiles_info_parser)
def do_get_profiles_info(self, opts):
"""Perform an ES10c GetProfilesInfo function.""" """Perform an ES10c GetProfilesInfo function."""
pi = CardApplicationISDR.store_data_tlv(self._cmd.lchan.scc, ProfileInfoListReq(), ProfileInfoListResp) if opts.all:
tags = [nest.tag for nest in ProfileInfo.nested_collection_cls().nested]
u8tags = []
# TODO: rework TagList to support 2 byte tags to not filter it into u8 tags
for tag in tags:
if tag <= 255:
u8tags.append(tag)
elif tag <= 65535:
u8tags.append(tag >> 8)
u8tags.append(tag & 0xff)
# Ignoring 3 byte tags
req = ProfileInfoListReq(children=[TagList(decoded=u8tags)])
else:
req = ProfileInfoListReq()
pi = CardApplicationISDR.store_data_tlv(self._cmd.lchan.scc, req, ProfileInfoListResp)
d = pi.to_dict() d = pi.to_dict()
self._cmd.poutput_json(flatten_dict_lists(d['profile_info_list_resp'])) self._cmd.poutput_json(flatten_dict_lists(d['profile_info_list_resp']))
+4 -3
View File
@@ -38,15 +38,16 @@ class SwMatchError(Exception):
"""Raised when an operation specifies an expected SW but the actual SW from """Raised when an operation specifies an expected SW but the actual SW from
the card doesn't match.""" the card doesn't match."""
def __init__(self, sw_actual: str, sw_expected: str, rs=None): def __init__(self, sw_actual: str, sw_expected, rs=None):
""" """
Args: Args:
sw_actual : the SW we actually received from the card (4 hex digits) sw_actual : the SW we actually received from the card (4 hex digits)
sw_expected : the SW we expected to receive from the card (4 hex digits) sw_expected : the SW we expected to receive from the card (4 hex digits),
or a list of acceptable ones
rs : interpreter class to convert SW to string rs : interpreter class to convert SW to string
""" """
self.sw_actual = sw_actual self.sw_actual = sw_actual
self.sw_expected = sw_expected self.sw_expected = '/'.join(sw_expected) if isinstance(sw_expected, (list, tuple)) else sw_expected
self.rs = rs self.rs = rs
@property @property
+89 -20
View File
@@ -30,6 +30,7 @@ import tempfile
import json import json
import abc import abc
import inspect import inspect
import os
import cmd2 import cmd2
from cmd2 import CommandSet, with_default_category from cmd2 import CommandSet, with_default_category
@@ -43,6 +44,7 @@ from pySim.utils import sw_match, decomposeATR
from pySim.jsonpath import js_path_modify from pySim.jsonpath import js_path_modify
from pySim.commands import SimCardCommands from pySim.commands import SimCardCommands
from pySim.exceptions import SwMatchError from pySim.exceptions import SwMatchError
from pySim.log import PySimLogger
# int: a single service is associated with this file # int: a single service is associated with this file
# list: any of the listed services requires this file # list: any of the listed services requires this file
@@ -51,6 +53,8 @@ CardFileService = Union[int, List[int], Tuple[int, ...]]
Size = Tuple[int, Optional[int]] Size = Tuple[int, Optional[int]]
log = PySimLogger.get(__name__)
class CardFile: class CardFile:
"""Base class for all objects in the smart card filesystem. """Base class for all objects in the smart card filesystem.
Serve as a common ancestor to all other file types; rarely used directly. Serve as a common ancestor to all other file types; rarely used directly.
@@ -552,6 +556,85 @@ class CardADF(CardDF):
return lchan.selected_file.application.export(as_json, lchan) 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): class CardEF(CardFile):
"""EF (Entry File) in the smart card filesystem""" """EF (Entry File) in the smart card filesystem"""
@@ -657,15 +740,8 @@ class TransparentEF(CardEF):
def do_edit_binary_decoded(self, _opts): def do_edit_binary_decoded(self, _opts):
"""Edit the JSON representation of the EF contents in an editor.""" """Edit the JSON representation of the EF contents in an editor."""
(orig_json, _sw) = self._cmd.lchan.read_binary_dec() (orig_json, _sw) = self._cmd.lchan.read_binary_dec()
with tempfile.TemporaryDirectory(prefix='pysim_') as dirname: ef = self._cmd.lchan.selected_file
filename = '%s/file' % dirname with JsonEditor(self._cmd, orig_json, ef) as edited_json:
# 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)
if edited_json == orig_json: if edited_json == orig_json:
self._cmd.poutput("Data not modified, skipping write") self._cmd.poutput("Data not modified, skipping write")
else: else:
@@ -959,15 +1035,8 @@ class LinFixedEF(CardEF):
def do_edit_record_decoded(self, opts): def do_edit_record_decoded(self, opts):
"""Edit the JSON representation of one record in an editor.""" """Edit the JSON representation of one record in an editor."""
(orig_json, _sw) = self._cmd.lchan.read_record_dec(opts.RECORD_NR) (orig_json, _sw) = self._cmd.lchan.read_record_dec(opts.RECORD_NR)
with tempfile.TemporaryDirectory(prefix='pysim_') as dirname: ef = self._cmd.lchan.selected_file
filename = '%s/file' % dirname with JsonEditor(self._cmd, orig_json, ef) as edited_json:
# 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)
if edited_json == orig_json: if edited_json == orig_json:
self._cmd.poutput("Data not modified, skipping write") self._cmd.poutput("Data not modified, skipping write")
else: else:
@@ -1543,14 +1612,14 @@ class CardModel(abc.ABC):
card_atr = scc.get_atr() card_atr = scc.get_atr()
for atr in cls._atrs: for atr in cls._atrs:
if atr == card_atr: if atr == card_atr:
print("Detected CardModel:", cls.__name__) log.info("Detected CardModel: %s", cls.__name__)
return True return True
# if nothing found try to just compare the Historical Bytes of the ATR # if nothing found try to just compare the Historical Bytes of the ATR
card_atr_hb = decomposeATR(card_atr)['hb'] card_atr_hb = decomposeATR(card_atr)['hb']
for atr in cls._atrs: for atr in cls._atrs:
atr_hb = decomposeATR(atr)['hb'] atr_hb = decomposeATR(atr)['hb']
if atr_hb == card_atr_hb: if atr_hb == card_atr_hb:
print("Detected CardModel:", cls.__name__) log.info("Detected CardModel: %s", cls.__name__)
return True return True
return False return False
+265 -54
View File
@@ -18,10 +18,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
""" """
import io import io
import hashlib
from copy import deepcopy from copy import deepcopy
from typing import Optional, List, Dict, Tuple from typing import Optional, List, Dict, Tuple
from construct import Optional as COptional from construct import Optional as COptional
from construct import Struct, GreedyRange, FlagsEnum, Int16ub, Int24ub, Padding, Bit, Const from construct import Struct, GreedyRange, FlagsEnum, Int16ub, Int24ub, Padding, Bit, Const
from construct import Construct, stream_read, stream_write
from Cryptodome.Random import get_random_bytes from Cryptodome.Random import get_random_bytes
from Cryptodome.Cipher import DES, DES3, AES from Cryptodome.Cipher import DES, DES3, AES
from osmocom.utils import * from osmocom.utils import *
@@ -35,6 +37,9 @@ from pySim.filesystem import *
from pySim.profile import CardProfile from pySim.profile import CardProfile
from pySim.ota import SimFileAccessAndToolkitAppSpecParams from pySim.ota import SimFileAccessAndToolkitAppSpecParams
from pySim.javacard import CapFile from pySim.javacard import CapFile
from pySim.log import PySimLogger
log = PySimLogger.get(__name__)
# GPCS Table 11-48 Load Parameter Tags # GPCS Table 11-48 Load Parameter Tags
class NonVolatileCodeMinMemoryReq(BER_TLV_IE, tag=0xC6): class NonVolatileCodeMinMemoryReq(BER_TLV_IE, tag=0xC6):
@@ -148,6 +153,24 @@ sw_table = {
}, },
} }
class PutKeyLength(Construct):
"""A length field of a PUT KEY data field, GP CardSpec v2.3.1 11.8.2.3.1
- all lengths ASN.1 BER-TLV (ITU-T X.690 Section 8.1.3)
- except that the length 128 may also be coded on one byte as '80' for backwards compatibility
80 does not introduce the indefinite form here which is unused in GP as far as i know.
That legacy form is accepted when parsing, but never generated, which agrees with the spec"""
def _parse(self, stream, context, path):
first = stream_read(stream, 1, path)[0]
if first <= 0x80:
return first
return int.from_bytes(stream_read(stream, first & 0x7f, path), 'big')
def _build(self, obj, stream, context, path):
data = bertlv_encode_len(obj)
stream_write(stream, data, len(data), path)
return obj
# GlobalPlatform 2.1.1 Section 9.1.6 # GlobalPlatform 2.1.1 Section 9.1.6
KeyType = Enum(Byte, des=0x80, KeyType = Enum(Byte, des=0x80,
tls_psk=0x85, # v2.3.1 Section 11.1.8 tls_psk=0x85, # v2.3.1 Section 11.1.8
@@ -276,7 +299,7 @@ class ListOfSupportedOptions(BER_TLV_IE, tag=0x81):
class SupportedKeysForScp03(BER_TLV_IE, tag=0x82): class SupportedKeysForScp03(BER_TLV_IE, tag=0x82):
_construct = FlagsEnum(Byte, aes128=0x01, aes192=0x02, aes256=0x04) _construct = FlagsEnum(Byte, aes128=0x01, aes192=0x02, aes256=0x04)
class SupportedTlsCipherSuitesForScp81(BER_TLV_IE, tag=0x83): class SupportedTlsCipherSuitesForScp81(BER_TLV_IE, tag=0x83):
_consuruct = GreedyRange(Int16ub) _construct = GreedyRange(Int16ub)
class ScpInformation(BER_TLV_IE, tag=0xa0, nested=[ScpType, ListOfSupportedOptions, SupportedKeysForScp03, class ScpInformation(BER_TLV_IE, tag=0xa0, nested=[ScpType, ListOfSupportedOptions, SupportedKeysForScp03,
SupportedTlsCipherSuitesForScp81]): SupportedTlsCipherSuitesForScp81]):
pass pass
@@ -319,7 +342,7 @@ class CurrentSecurityLevel(BER_TLV_IE, tag=0xd3):
# GlobalPlatform v2.3.1 Section 11.3.3.1.3 # GlobalPlatform v2.3.1 Section 11.3.3.1.3
class ApplicationAID(BER_TLV_IE, tag=0x4f): class ApplicationAID(BER_TLV_IE, tag=0x4f):
_construct = GreedyBytes _construct = GreedyBytes
class ApplicationTemplate(BER_TLV_IE, tag=0x61, ntested=[ApplicationAID]): class ApplicationTemplate(BER_TLV_IE, tag=0x61, nested=[ApplicationAID]):
pass pass
class ListOfApplications(BER_TLV_IE, tag=0x2f00, nested=[ApplicationTemplate]): class ListOfApplications(BER_TLV_IE, tag=0x2f00, nested=[ApplicationTemplate]):
pass pass
@@ -512,6 +535,63 @@ class GpRegistryRelatedData(BER_TLV_IE, tag=0xe3, nested=[ApplicationAID, LifeCy
ExecutableModuleAID, AssociatedSecurityDomainAID]): ExecutableModuleAID, AssociatedSecurityDomainAID]):
pass pass
# GP CS v2.3.1 Table 11-36/11-37 possible data objects requested/returned from GET STATUS for each registry entry.
# Applications and Executable Load Files have _different_ sets, so a tag list requesting them has
# to match the subset because 11.4.2.3 warns that asking for a data object an entry does not have
# "may" be answered with an error status.
GetStatusTagListIEs = {
# Table 11-36 GP Application Data
'isd': [ApplicationAID, LifeCycleState, Privileges, ImplicitSelectionParameter,
ExecutableLoadFileAID, AssociatedSecurityDomainAID],
'applications': [ApplicationAID, LifeCycleState, Privileges, ImplicitSelectionParameter,
ExecutableLoadFileAID, AssociatedSecurityDomainAID],
# Table 11-37 GP Executable Load File Data. 84 only for the subset that asks for the modules (Note 2)!
'files': [ApplicationAID, LifeCycleState, ExecutableLoadFileVersionNumber,
AssociatedSecurityDomainAID],
'files_and_modules': [ApplicationAID, LifeCycleState, ExecutableLoadFileVersionNumber,
ExecutableModuleAID, AssociatedSecurityDomainAID],
}
def get_status_tag_list(subset: str) -> bytes:
"""Encode the GET STATUS tag list for the given status subset"""
tags = b''.join([bertlv_encode_tag(ie.tag) for ie in GetStatusTagListIEs[subset]])
return b'\x5c' + bertlv_encode_len(len(tags)) + tags
# GP CS v2.3.1 Appendix H.2 / Table H-1
# oid prefix {iso(1) member-body(2) country-USA(840) globalPlatform(114283)} + card management type 2
# afterwards GP version.
OID_GP_CARD_MGMT_TYPE = h2b('2a864886fc6b02')
def _find_tlv_value(decoded, key: str):
"""depth first search for the nested decoded TLV_IE dict/list"""
if isinstance(decoded, dict):
for k, v in decoded.items():
if k == key:
return v
found = _find_tlv_value(v, key)
if found is not None:
return found
elif isinstance(decoded, list):
for item in decoded:
found = _find_tlv_value(item, key)
if found is not None:
return found
return None
def decode_gp_version(card_data: bytes) -> Optional[Tuple[int, ...]]:
"""GP version from Card Data returned by GET DATA, like (2, 1, 1) or (2, 2).
None if cm type OID is absent/unknown"""
cd = CardData()
cd.from_tlv(card_data)
ctv = _find_tlv_value(cd.to_dict(), 'card_management_type_and_version')
oid = _find_tlv_value(ctv, 'object_identifier') if ctv is not None else None
if oid is None:
return None
oid = h2b(oid) if isinstance(oid, str) else bytes(oid)
if not oid.startswith(OID_GP_CARD_MGMT_TYPE):
return None
return tuple(oid[len(OID_GP_CARD_MGMT_TYPE):])
# Application Dedicated File of a Security Domain # Application Dedicated File of a Security Domain
class ADF_SD(CardADF): class ADF_SD(CardADF):
StoreData = BitStruct('last_block'/Flag, StoreData = BitStruct('last_block'/Flag,
@@ -562,14 +642,14 @@ class ADF_SD(CardADF):
@cmd2.with_argparser(store_data_parser) @cmd2.with_argparser(store_data_parser)
def do_store_data(self, opts): def do_store_data(self, opts):
"""Perform the GlobalPlatform GET DATA command in order to store some card-specific data. """Perform the GlobalPlatform STORE DATA command in order to store some card-specific data.
See GlobalPlatform CardSpecification v2.3Section 11.11 for details.""" See GlobalPlatform CardSpecification v2.3 Section 11.11 for details."""
response_permitted = opts.response == 'may_be_returned' response_permitted = opts.response == 'may_be_returned'
self.store_data(h2b(opts.DATA), opts.data_structure, opts.encryption, response_permitted) 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: 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. """Perform the GlobalPlatform STORE DATA command in order to store some card-specific data.
See GlobalPlatform CardSpecification v2.3Section 11.11 for details.""" See GlobalPlatform CardSpecification v2.3 Section 11.11 for details."""
max_cmd_len = self._cmd.lchan.scc.max_cmd_len max_cmd_len = self._cmd.lchan.scc.max_cmd_len
# Table 11-89 of GP Card Specification v2.3 # Table 11-89 of GP Card Specification v2.3
remainder = data remainder = data
@@ -585,7 +665,7 @@ class ADF_SD(CardADF):
data, _sw = self._cmd.lchan.scc.send_apdu_checksw(hdr + b2h(chunk) + "00") data, _sw = self._cmd.lchan.scc.send_apdu_checksw(hdr + b2h(chunk) + "00")
block_nr += 1 block_nr += 1
response += data response += data
return data return h2b(response)
put_key_parser = argparse.ArgumentParser() put_key_parser = argparse.ArgumentParser()
put_key_parser.add_argument('--old-key-version-nr', type=auto_uint8, default=0, help='Old Key Version Number') put_key_parser.add_argument('--old-key-version-nr', type=auto_uint8, default=0, help='Old Key Version Number')
@@ -602,8 +682,8 @@ class ADF_SD(CardADF):
See GlobalPlatform CardSpecification v2.3 Section 11.8 for details. See GlobalPlatform CardSpecification v2.3 Section 11.8 for details.
The KCV (Key Check Values) can either be explicitly specified using `--key-check`, or will The KCV (Key Check Values) can either be explicitly specified using `--key-check`, or will
otherwise be automatically generated for DES and AES keys. You can suppress the latter using otherwise be automatically generated for DES, AES and TLS-PSK keys. You can suppress the
`--suppress-key-check`. latter using `--suppress-key-check`.
Example (SCP80 KIC/KID/KIK): Example (SCP80 KIC/KID/KIK):
put_key --key-version-nr 1 --key-id 0x01 --key-type aes --key-data 000102030405060708090a0b0c0d0e0f put_key --key-version-nr 1 --key-id 0x01 --key-type aes --key-data 000102030405060708090a0b0c0d0e0f
@@ -620,33 +700,81 @@ class ADF_SD(CardADF):
kdb = [] kdb = []
for i in range(0, len(opts.key_type)): for i in range(0, len(opts.key_type)):
if opts.key_check and len(opts.key_check) > i: if opts.key_check and len(opts.key_check) > i:
kcv = opts.key_check[i] kcv = h2b(opts.key_check[i])
elif opts.suppress_key_check: elif opts.suppress_key_check:
kcv = '' kcv = b''
else: else:
kcv_bin = compute_kcv(opts.key_type[i], h2b(opts.key_data[i])) or b'' kcv = compute_kcv(opts.key_type[i], h2b(opts.key_data[i])) or b''
kcv = b2h(kcv_bin) kdb.append({'key_type': opts.key_type[i], 'clear_key': h2b(opts.key_data[i]), 'kcv': kcv})
if self._cmd.lchan.scc.scp:
# encrypted key data with DEK of current SCP
kcb = b2h(self._cmd.lchan.scc.scp.encrypt_key(h2b(opts.key_data[i])))
else:
# (for example) during personalization, DEK might not be required)
kcb = opts.key_data[i]
kdb.append({'key_type': opts.key_type[i], 'kcb': kcb, 'kcv': kcv})
p2 = opts.key_id p2 = opts.key_id
if len(opts.key_type) > 1: if len(opts.key_type) > 1:
p2 |= 0x80 p2 |= 0x80
self.put_key(opts.old_key_version_nr, opts.key_version_nr, p2, kdb) self.put_key(opts.old_key_version_nr, opts.key_version_nr, p2, kdb)
# Table 11-68: Key Data Field - Format 1 (Basic Format) # Table 11-68: Key Data Field - Format 1 (Basic Format). The key component block length is
KeyDataBasic = GreedyRange(Struct('key_type'/KeyType, # BER-TLV coded (Section 11.8.2.3.1), the key check value length is always '00' - '7F'.
'kcb'/Prefixed(Int8ub, GreedyBytes), KeyDataBasic = Struct('key_type'/KeyType,
'kcv'/Prefixed(Int8ub, GreedyBytes))) 'kcb'/Prefixed(PutKeyLength(), GreedyBytes),
'kcv'/Prefixed(Int8ub, GreedyBytes))
def put_key(self, old_kvn:int, kvn: int, kid: int, key_dict: dict) -> bytes: @classmethod
def encode_key_data_basic(cls, key_type: str, kcb: bytes, kcv: bytes) -> bytes:
"""Generic Basic key data field, GP CardSpec v2.3 Table 11-68):
tag || L1 || <maybe L2> KCB || <1-byte length> KCV"""
return cls.KeyDataBasic.build({'key_type': key_type, 'kcb': kcb, 'kcv': kcv})
@classmethod
def encode_key_data_psk(cls, clear_key: bytes, ciphered_key: bytes, kcv: bytes) -> bytes:
"""Single PSK TLS '85' key data field per GP Amendment B 1.2, 3.9.1 / Table 3-13:
85 | L1 | <L2> <ciphered PSK key> | <KCV length> | <KCV>
- framing is like Basic Format, but the kcb is always GP CardSpec Table 11-70
so always with the length of the clear text key value, even without padding!
- 'ciphered_key' is DEK(block-padded clear key), no additional length prefix."""
kcb = bertlv_encode_len(len(clear_key)) + ciphered_key
return cls.encode_key_data_basic('tls_psk', kcb, kcv)
@classmethod
def build_put_key_data(cls, kvn: int, keys: List[dict], scp) -> bytes:
"""Assemble the PUT KEY data field, mixed PSK + DES DEK is supported:
- new KVN followed by one key data field per key.
- tls_psk keys per GP Amendment B
- other key types generic Basic format
Param 'keys' is a dict:
- 'key_type' (str)
- 'clear_key' (bytes)
- 'kcv' (bytes / empty).
'scp' may be None (e.g. during personalization, when the DEK may not be required)."""
key_data = kvn.to_bytes(1, 'big')
for k in keys:
clear = k['clear_key']
if k['key_type'] == 'tls_psk':
# len always part of the data see CardSpec Table 11-70 vs Table 11-71
if scp:
ciphered = scp.dek_encrypt(scp.pad_to_blocksize(clear))
else:
ciphered = clear
key_data += cls.encode_key_data_psk(clear, ciphered, k['kcv'])
else:
if scp:
ciphered = scp.encrypt_key(clear)
else:
# (for example) during personalization, DEK might not be required
ciphered = clear
key_data += cls.encode_key_data_basic(k['key_type'], ciphered, k['kcv'])
return key_data
def put_key(self, old_kvn:int, kvn: int, kid: int, keys: List[dict]) -> bytes:
"""Perform the GlobalPlatform PUT KEY command in order to store a new key on the card. """Perform the GlobalPlatform PUT KEY command in order to store a new key on the card.
See GlobalPlatform CardSpecification v2.3 Section 11.8 for details.""" See GlobalPlatform CardSpecification v2.3 Section 11.8 for details."""
key_data = kvn.to_bytes(1, 'big') + build_construct(ADF_SD.AddlShellCommands.KeyDataBasic, key_dict) key_data = self.build_put_key_data(kvn, keys, self._cmd.lchan.scc.scp)
# Lc of Table 11-64 is a single byte, while LOAD or STORE DATA splits we can't:
# 11.8.2.3.3 splits a key at component boundaries -> not helping here
max_cmd_len = self._cmd.lchan.scc.max_cmd_len
if len(key_data) > max_cmd_len:
raise ValueError('key data field of %u bytes exceeds the maximum command length of %u '
'(limited by the overhead of the current secure channel); use fewer '
'keys per command, a single key component that large needs STORE DATA' %
(len(key_data), max_cmd_len))
hdr = "80D8%02x%02x%02x" % (old_kvn, kid, len(key_data)) hdr = "80D8%02x%02x%02x" % (old_kvn, kid, len(key_data))
data, _sw = self._cmd.lchan.scc.send_apdu_checksw(hdr + b2h(key_data) + "00") data, _sw = self._cmd.lchan.scc.send_apdu_checksw(hdr + b2h(key_data) + "00")
return data return data
@@ -665,26 +793,78 @@ class ADF_SD(CardADF):
for grd in grd_list: for grd in grd_list:
self._cmd.poutput_json(grd.to_dict()) self._cmd.poutput_json(grd.to_dict())
def gp_version(self) -> Optional[Tuple[int, ...]]:
"""GP version the selected SD reports in its Card Recognition
Data, e.g. (2, 1, 1). Card Recognition Data "shall be present" v2.1.1/v2.3.1 section 7.4.1.3,
so this must succeed no matter the GP version. None if card did not answer GET DATA / OID unknown.
Cached, it cannot change during a session."""
if not hasattr(self, '_gp_version'):
self._gp_version = None
try:
data, _sw = self._cmd.lchan.scc.get_data(cla=0x80, tag=CardData.tag)
self._gp_version = decode_gp_version(h2b(data))
except (SwMatchError, ValueError) as e:
log.warning("Could not determine GlobalPlatform version: %s", e)
return self._gp_version
def get_status(self, subset:str, aid_search_qualifier:Hexstr = '') -> List[GpRegistryRelatedData]: def get_status(self, subset:str, aid_search_qualifier:Hexstr = '') -> List[GpRegistryRelatedData]:
subset_hex = b2h(build_construct(StatusSubset, subset))
aid = ApplicationAID(decoded=aid_search_qualifier) aid = ApplicationAID(decoded=aid_search_qualifier)
cmd_data = aid.to_tlv() + h2b('5c054f9f70c5cc') # GPC CardSpec v2.3.1 Table 11-35 says only the AID search tag is mandatory, tag list is
p2 = 0x02 # TLV format according to Table 11-36 # Optional and not present in the older v2.1.1, where section 9.4.2.3 defines the data
# field as the search qualifier.
# Cards like the sja5 implementing that old GP version reject anything else with 6A80
# from v2.1.1 Table 9-26 so only send a tag list to a card that announces v2.2 or later.
#
# Not sending one is not a problem on older cards, the tag list only gives us data beyond
# what 11.4.3.1 gives us anyway, for example the associated SD AID which matters on an eUICC
# where entries belong to different SD.
version = self.gp_version()
log.debug("Card Recognition Data reports GlobalPlatform %s",
'.'.join(str(v) for v in version) if version else 'unknown')
if version is not None and version >= (2, 2):
try:
return self._get_status(subset, aid.to_tlv() + get_status_tag_list(subset))
except SwMatchError as e:
# Retry if v2.2 or later but rejected the tag list anyway.
# 6A80 and 6A88 are the error conditions GET STATUS defines in table 11-39.
# Retrying beats not ending up with a list again...
if e.sw_actual not in ('6a80', '6a88'):
raise
log.warning("Card reports GlobalPlatform %s but answered %s to the GET STATUS tag list; "
"retrying with the default search",
'.'.join(str(v) for v in version), e.sw_actual)
return self._get_status(subset, aid.to_tlv(), empty_on_6a88=True)
def _get_status(self, subset:str, cmd_data:bytes,
empty_on_6a88: bool = False) -> List[GpRegistryRelatedData]:
subset_hex = b2h(build_construct(StatusSubset, subset))
p2 = 0x02 # GPC v2.3.1 11.4.2.2 table 11-34, b2: response data structure per table 11-36
grd_list = [] grd_list = []
while True: while True:
hdr = "80F2%s%02x%02x" % (subset_hex, p2, len(cmd_data)) hdr = "80F2%s%02x%02x" % (subset_hex, p2, len(cmd_data))
data, sw = self._cmd.lchan.scc.send_apdu(hdr + b2h(cmd_data) + "00") data, sw = self._cmd.lchan.scc.send_apdu(hdr + b2h(cmd_data) + "00")
if sw == '6a88':
# Table 11-39 "Referenced data not found". After collecting all pages this can
# only mean "nothing more matches" -> listing is complete. On the first page
# it is ambiguous, empty result or bad command data field, so leave that to get_status()
# which knows if a tag list was sent.
if grd_list or empty_on_6a88:
return grd_list
raise SwMatchError(sw, ['9000', '6310'])
if sw not in ['9000', '6310']:
# Never return a silently truncated registry
raise SwMatchError(sw, ['9000', '6310'])
remainder = h2b(data) remainder = h2b(data)
while len(remainder): while len(remainder):
# tlv sequence, each element is one GpRegistryRelatedData() # tlv sequence, each element is one GpRegistryRelatedData()
grd = GpRegistryRelatedData() grd = GpRegistryRelatedData()
_dec, remainder = grd.from_tlv(remainder) _dec, remainder = grd.from_tlv(remainder)
grd_list.append(grd) grd_list.append(grd)
if sw != '6310': if sw == '9000':
return grd_list return grd_list
else: # 6310 = more data available, table 11-38: reissue as get next occurrence(s), b1 of
p2 |= 0x01 # table 11-34. Keeps b2 unchanged.
return grd_list p2 |= 0x01
set_status_parser = argparse.ArgumentParser() set_status_parser = argparse.ArgumentParser()
set_status_parser.add_argument('scope', choices=list(SetStatusScope.ksymapping.values()), set_status_parser.add_argument('scope', choices=list(SetStatusScope.ksymapping.values()),
@@ -826,23 +1006,32 @@ class ADF_SD(CardADF):
load_parser_from_grp.add_argument('--from-hex', type=is_hexstr, help='load from hex string') load_parser_from_grp.add_argument('--from-hex', type=is_hexstr, help='load from hex string')
load_parser_from_grp.add_argument('--from-file', type=argparse.FileType('rb', 0), help='load from binary file') load_parser_from_grp.add_argument('--from-file', type=argparse.FileType('rb', 0), help='load from binary file')
load_parser_from_grp.add_argument('--from-cap-file', type=argparse.FileType('rb', 0), help='load from JAVA-card CAP file') load_parser_from_grp.add_argument('--from-cap-file', type=argparse.FileType('rb', 0), help='load from JAVA-card CAP file')
load_parser.add_argument('--chunk-len', type=auto_uint8, default=None,
help='Block size for the LOAD command; default: as large as the current secure channel overhead permits, at most 240')
@cmd2.with_argparser(load_parser) @cmd2.with_argparser(load_parser)
def do_load(self, opts): def do_load(self, opts):
"""Perform a GlobalPlatform LOAD command. (We currently only support loading without DAP and """Perform a GlobalPlatform LOAD command. (We currently only support loading without DAP and
without ciphering.)""" without ciphering.)"""
if opts.from_hex is not None: if opts.from_hex is not None:
self.load(h2b(opts.from_hex)) self.load(h2b(opts.from_hex), opts.chunk_len)
elif opts.from_file is not None: elif opts.from_file is not None:
self.load(opts.from_file.read()) self.load(opts.from_file.read(), opts.chunk_len)
elif opts.from_cap_file is not None: elif opts.from_cap_file is not None:
cap = CapFile(opts.from_cap_file) cap = CapFile(opts.from_cap_file)
self.load(cap.get_loadfile()) self.load(cap.get_loadfile(), opts.chunk_len)
else: else:
raise ValueError('load source not specified!') raise ValueError('load source not specified!')
def load(self, contents:bytes, chunk_len:int = 240): def load(self, contents:bytes, chunk_len:Optional[int] = None):
# TODO:tune chunk_len based on the overhead of the used SCP? # scc.max_cmd_len knows the overhead the currently active SCP
# 240 is the old default, keep it for now.
max_chunk_len = self._cmd.lchan.scc.max_cmd_len
if chunk_len is None:
chunk_len = min(240, max_chunk_len)
elif not 1 <= chunk_len <= max_chunk_len:
raise ValueError('chunk_len must be in range 1..%u (limited by the overhead of the current secure channel)' %
max_chunk_len)
# build TLV according to GPC_SPE_034 section 11.6.2.3 / Table 11-58 for unencrypted case # build TLV according to GPC_SPE_034 section 11.6.2.3 / Table 11-58 for unencrypted case
remainder = b'\xC4' + bertlv_encode_len(len(contents)) + contents remainder = b'\xC4' + bertlv_encode_len(len(contents)) + contents
# transfer this in various chunks to the card # transfer this in various chunks to the card
@@ -859,22 +1048,30 @@ class ADF_SD(CardADF):
_rsp_hex, _sw = self._cmd.lchan.scc.send_apdu_checksw(cmd_hex) _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)) 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', install_cap_parser.add_argument('cap_file', type=str, metavar='FILE',
help='JAVA-CARD CAP file to install') help='JAVA-CARD CAP file to install')
install_cap_parser_inst_prm_g = install_cap_parser.add_mutually_exclusive_group() # Ideally, the parser should enforce that:
install_cap_parser_inst_prm_g.add_argument('--install-parameters', type=is_hexstr, default=None, # * either the `--install-parameters` is given alone,
help='install Parameters (GPC_SPE_034, section 11.5.2.3.7, table 11-49)') # * or distinct `--install-parameters-*` are optionally given instead.
install_cap_parser_inst_prm_g_grp = install_cap_parser_inst_prm_g.add_argument_group() # We tried to achieve this using mutually exclusive groups (add_mutually_exclusive_group).
install_cap_parser_inst_prm_g_grp.add_argument('--install-parameters-volatile-memory-quota', # However, group nesting was never supported, often failed to work correctly, and was unintentionally
type=int, default=None, # exposed through inheritance. It has been deprecated since version 3.11, removed in version 3.14.
help='volatile memory quota (GPC_SPE_034, section 11.5.2.3.7, table 11-49)') # Hence, we have to implement the enforcement manually.
install_cap_parser_inst_prm_g_grp.add_argument('--install-parameters-non-volatile-memory-quota', install_cap_parser_inst_prm_grp = install_cap_parser.add_argument_group('Install Parameters')
type=int, default=None, install_cap_parser_inst_prm_grp.add_argument('--install-parameters', type=is_hexstr, default=None,
help='non volatile memory quota (GPC_SPE_034, section 11.5.2.3.7, table 11-49)') help='install Parameters (GPC_SPE_034, section 11.5.2.3.7, table 11-49)')
install_cap_parser_inst_prm_g_grp.add_argument('--install-parameters-stk', install_cap_parser_inst_prm_grp.add_argument('--install-parameters-volatile-memory-quota',
type=is_hexstr, default=None, type=int, default=None,
help='Load Parameters (ETSI TS 102 226, section 8.2.1.3.2.1)') 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)')
install_cap_parser.add_argument('--chunk-len', type=auto_uint8, default=None,
help='Block size for the LOAD command; default: as large as the current secure channel overhead permits, at most 240')
@cmd2.with_argparser(install_cap_parser) @cmd2.with_argparser(install_cap_parser)
def do_install_cap(self, opts): def do_install_cap(self, opts):
@@ -888,9 +1085,17 @@ class ADF_SD(CardADF):
load_file_aid = cap.get_loadfile_aid() load_file_aid = cap.get_loadfile_aid()
module_aid = cap.get_applet_aid() module_aid = cap.get_applet_aid()
application_aid = module_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; install_parameters = opts.install_parameters;
else: else:
# `--install-parameters-*` are all optional
install_parameters = gen_install_parameters(opts.install_parameters_non_volatile_memory_quota, install_parameters = gen_install_parameters(opts.install_parameters_non_volatile_memory_quota,
opts.install_parameters_volatile_memory_quota, opts.install_parameters_volatile_memory_quota,
opts.install_parameters_stk) opts.install_parameters_stk)
@@ -905,7 +1110,7 @@ class ADF_SD(CardADF):
self._cmd.poutput("step #1: install for load...") self._cmd.poutput("step #1: install for load...")
self.do_install_for_load("--load-file-aid %s --security-domain-aid %s" % (load_file_aid, security_domain_aid)) self.do_install_for_load("--load-file-aid %s --security-domain-aid %s" % (load_file_aid, security_domain_aid))
self._cmd.poutput("step #2: load...") self._cmd.poutput("step #2: load...")
self.load(load_file) self.load(load_file, opts.chunk_len)
self._cmd.poutput("step #3: install_for_install (and make selectable)...") self._cmd.poutput("step #3: install_for_install (and make selectable)...")
self.do_install_for_install("--load-file-aid %s --module-aid %s --application-aid %s --install-parameters %s --make-selectable" % self.do_install_for_install("--load-file-aid %s --module-aid %s --application-aid %s --install-parameters %s --make-selectable" %
(load_file_aid, module_aid, application_aid, install_parameters)) (load_file_aid, module_aid, application_aid, install_parameters))
@@ -1051,10 +1256,16 @@ def compute_kcv_aes(key:bytes) -> bytes:
cipher = AES.new(key, AES.MODE_ECB) cipher = AES.new(key, AES.MODE_ECB)
return cipher.encrypt(plaintext) return cipher.encrypt(plaintext)
def compute_kcv_psk(key:bytes) -> bytes:
# GP Amendment B v1.2, 3.9.1 / Table 3-13
# KCV of a PSK TLS key is the 3 highest-order bytes of the SHA-1 digest of the clear key value.
return hashlib.sha1(key).digest()
# dict is keyed by the string name of the KeyType enum above in this file # dict is keyed by the string name of the KeyType enum above in this file
KCV_CALCULATOR = { KCV_CALCULATOR = {
'aes': compute_kcv_aes, 'aes': compute_kcv_aes,
'des': compute_kcv_des, 'des': compute_kcv_des,
'tls_psk': compute_kcv_psk,
} }
def compute_kcv(key_type: str, key: bytes) -> Optional[bytes]: def compute_kcv(key_type: str, key: bytes) -> Optional[bytes]:
+16 -14
View File
@@ -17,6 +17,8 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from typing import Optional
from osmocom.construct import * from osmocom.construct import *
from osmocom.utils import * from osmocom.utils import *
from osmocom.tlv import * from osmocom.tlv import *
@@ -46,7 +48,9 @@ class InstallParams(TLV_IE_Collection, nested=[AppSpecificParams, SystemSpecific
# GPD_SPE_013, table 11-49 # GPD_SPE_013, table 11-49
pass 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 # 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 = InstallParams()
install_params_dict = [{'app_specific_params': None}] install_params_dict = [{'app_specific_params': None}]
#Conditional # Collect system specific parameters (optional)
if non_volatile_memory_quota and volatile_memory_quota and stk_parameter: system_specific_params = []
system_specific_params = [] if non_volatile_memory_quota is not None:
#Optional system_specific_params.append({'non_volatile_memory_quota': non_volatile_memory_quota})
if non_volatile_memory_quota: if volatile_memory_quota is not None:
system_specific_params += [{'non_volatile_memory_quota': non_volatile_memory_quota}] system_specific_params.append({'volatile_memory_quota': volatile_memory_quota})
#Optional if stk_parameter is not None:
if volatile_memory_quota: system_specific_params.append({'stk_parameter': stk_parameter})
system_specific_params += [{'volatile_memory_quota': volatile_memory_quota}] # Add system specific parameters to the install parameters, if any
#Optional if system_specific_params:
if stk_parameter: install_params_dict.append({'system_specific_params': system_specific_params})
system_specific_params += [{'stk_parameter': stk_parameter}]
install_params_dict += [{'system_specific_params': system_specific_params}]
install_params.from_dict(install_params_dict) install_params.from_dict(install_params_dict)
return b2h(install_params.to_bytes()) return b2h(install_params.to_bytes())
+69 -30
View File
@@ -27,9 +27,9 @@ from osmocom.utils import b2h
from osmocom.tlv import bertlv_parse_len, bertlv_encode_len from osmocom.tlv import bertlv_parse_len, bertlv_encode_len
from pySim.utils import parse_command_apdu from pySim.utils import parse_command_apdu
from pySim.secure_channel import SecureChannel from pySim.secure_channel import SecureChannel
from pySim.log import PySimLogger
logger = logging.getLogger(__name__) log = PySimLogger.get(__name__)
logger.setLevel(logging.DEBUG)
def scp02_key_derivation(constant: bytes, counter: int, base_key: bytes) -> bytes: def scp02_key_derivation(constant: bytes, counter: int, base_key: bytes) -> bytes:
assert len(constant) == 2 assert len(constant) == 2
@@ -75,7 +75,7 @@ class Scp02SessionKeys:
h = e.encrypt(strxor(h, bytes(padded_data[8*i:8*(i+1)]))) h = e.encrypt(strxor(h, bytes(padded_data[8*i:8*(i+1)])))
h = d.decrypt(h) h = d.decrypt(h)
h = e.encrypt(h) h = e.encrypt(h)
logger.debug("mac_1des(%s,icv=%s) -> %s", b2h(data), b2h(icv), b2h(h)) log.debug("mac_1des(%s,icv=%s) -> %s", b2h(data), b2h(icv), b2h(h))
if self.des_icv_enc: if self.des_icv_enc:
self.icv = self.des_icv_enc.encrypt(h) self.icv = self.des_icv_enc.encrypt(h)
else: else:
@@ -89,7 +89,7 @@ class Scp02SessionKeys:
h = b'\x00' * 8 h = b'\x00' * 8
for i in range(q): for i in range(q):
h = e.encrypt(strxor(h, bytes(padded_data[8*i:8*(i+1)]))) h = e.encrypt(strxor(h, bytes(padded_data[8*i:8*(i+1)])))
logger.debug("mac_3des(%s) -> %s", b2h(data), b2h(h)) log.debug("mac_3des(%s) -> %s", b2h(data), b2h(h))
return h return h
def __init__(self, counter: int, card_keys: 'GpCardKeyset', icv_encrypt=True): def __init__(self, counter: int, card_keys: 'GpCardKeyset', icv_encrypt=True):
@@ -182,6 +182,29 @@ class SCP(SecureChannel, abc.ABC):
"""Should we perform R-ENC?""" """Should we perform R-ENC?"""
return self.security_level & 0x20 return self.security_level & 0x20
@property
@abc.abstractmethod
def mac_len(self) -> int:
"""Length of the appended C-MAC, to be provided by derived class."""
@property
def overhead(self) -> int:
"""Worst-case len that wrapping a command APDU adds to its data field at the
current sec level is (255 - overhead), C-MAC + C-DECRYPTION encryption padding."""
if not self.do_cmac:
return 0
if not self.do_cenc:
return self.mac_len
# see Secure Channel Protocol '03' Card Specification v2.3 - Amendment D v1.1.2
# which defers to GPCS v2.3 Section B.2 which then defers to
# NIST SP 800-38B for encryption and points out that
# the padding is, as expected, just the usual padding from NIST SP 800-38A
# C-DECRYPTION pads with ('80'+['00'...] at least 1 byte) up to
# the cipher block size + C-MAC on top -> largest usable data field
# is one byte less than the largest block-size multiple within 255 - mac_len.
bs = self.sk.blocksize
return 255 - ((255 - self.mac_len) // bs * bs - 1)
def __str__(self) -> str: def __str__(self) -> str:
return "%s[%02x]" % (self.__class__.__name__, self.security_level) return "%s[%02x]" % (self.__class__.__name__, self.security_level)
@@ -215,11 +238,20 @@ class SCP(SecureChannel, abc.ABC):
def gen_ext_auth_apdu(self, security_level: int = 0x01) -> bytes: def gen_ext_auth_apdu(self, security_level: int = 0x01) -> bytes:
pass pass
def pad_to_blocksize(self, data: bytes) -> bytes:
"""Right pad the data with zero bytes to a multiple of the DEK cipher block size."""
if len(data) % self.sk.blocksize:
# not '+=' which would mutate the callers bytearray in place..
data = data + b'\x00' * (self.sk.blocksize - len(data) % self.sk.blocksize)
return data
def encrypt_key(self, key: bytes) -> bytes: def encrypt_key(self, key: bytes) -> bytes:
"""Encrypt a key with the DEK.""" """Encrypt a key with the DEK."""
num_pad = len(key) % self.sk.blocksize if len(key) % self.sk.blocksize:
if num_pad: # The kcv is right padded before encryption and the kcb
return bertlv_encode_len(len(key)) + self.dek_encrypt(key + b'\x00'*num_pad) # is formatted as described in Table 11-70: preceded by the actual length of the
# clear text kcv.
return bertlv_encode_len(len(key)) + self.dek_encrypt(self.pad_to_blocksize(key))
return self.dek_encrypt(key) return self.dek_encrypt(key)
def decrypt_key(self, encrypted_key:bytes) -> bytes: def decrypt_key(self, encrypted_key:bytes) -> bytes:
@@ -232,9 +264,8 @@ class SCP(SecureChannel, abc.ABC):
# Block provides the actual length of the key component value, which allows recovering the # Block provides the actual length of the key component value, which allows recovering the
# clear-text key component value after decryption of the encrypted key component value and removal # clear-text key component value after decryption of the encrypted key component value and removal
# of padding bytes. # of padding bytes.
decrypted = self.dek_decrypt(encrypted_key) key_len, remainder = bertlv_parse_len(encrypted_key)
key_len, remainder = bertlv_parse_len(decrypted) return self.dek_decrypt(remainder)[:key_len]
return remainder[:key_len]
else: else:
# If the length of the Key Component Block is a multiple of the block size of the encryption # If the length of the Key Component Block is a multiple of the block size of the encryption
# algorithm (i.e. 8 bytes for DES, 16 bytes for AES), then it shall be assumed that no padding # algorithm (i.e. 8 bytes for DES, 16 bytes for AES), then it shall be assumed that no padding
@@ -260,10 +291,8 @@ class SCP02(SCP):
# Key Version Number 0x70 is a non-spec special-case of sysmoISIM-SJA2/SJA5 and possibly more sysmocom products # Key Version Number 0x70 is a non-spec special-case of sysmoISIM-SJA2/SJA5 and possibly more sysmocom products
# Key Version Number 0x01 is a non-spec special-case of sysmoUSIM-SJS1 # Key Version Number 0x01 is a non-spec special-case of sysmoUSIM-SJS1
kvn_ranges = [[0x01, 0x01], [0x20, 0x2f], [0x70, 0x70]] kvn_ranges = [[0x01, 0x01], [0x20, 0x2f], [0x70, 0x70]]
# C-MAC (Single DES + final 3DES, B.1.2.2) is always one full DES block
def __init__(self, *args, **kwargs): mac_len = 8
self.overhead = 8
super().__init__(*args, **kwargs)
def dek_encrypt(self, plaintext:bytes) -> bytes: def dek_encrypt(self, plaintext:bytes) -> bytes:
# See also GPC section B.1.1.2, E.4.7, and E.4.1 # See also GPC section B.1.1.2, E.4.7, and E.4.1
@@ -276,10 +305,10 @@ class SCP02(SCP):
return cipher.decrypt(ciphertext) return cipher.decrypt(ciphertext)
def _compute_cryptograms(self, card_challenge: bytes, host_challenge: bytes): def _compute_cryptograms(self, card_challenge: bytes, host_challenge: bytes):
logger.debug("host_challenge(%s), card_challenge(%s)", b2h(host_challenge), b2h(card_challenge)) log.debug("host_challenge(%s), card_challenge(%s)", b2h(host_challenge), b2h(card_challenge))
self.host_cryptogram = self.sk.calc_mac_3des(self.sk.counter.to_bytes(2, 'big') + card_challenge + host_challenge) self.host_cryptogram = self.sk.calc_mac_3des(self.sk.counter.to_bytes(2, 'big') + card_challenge + host_challenge)
self.card_cryptogram = self.sk.calc_mac_3des(self.host_challenge + self.sk.counter.to_bytes(2, 'big') + card_challenge) self.card_cryptogram = self.sk.calc_mac_3des(self.host_challenge + self.sk.counter.to_bytes(2, 'big') + card_challenge)
logger.debug("host_cryptogram(%s), card_cryptogram(%s)", b2h(self.host_cryptogram), b2h(self.card_cryptogram)) log.debug("host_cryptogram(%s), card_cryptogram(%s)", b2h(self.host_cryptogram), b2h(self.card_cryptogram))
def gen_init_update_apdu(self, host_challenge: bytes = b'\x00'*8) -> bytes: def gen_init_update_apdu(self, host_challenge: bytes = b'\x00'*8) -> bytes:
"""Generate INITIALIZE UPDATE APDU.""" """Generate INITIALIZE UPDATE APDU."""
@@ -291,7 +320,7 @@ class SCP02(SCP):
resp = self.constr_iur.parse(resp_bin) resp = self.constr_iur.parse(resp_bin)
self.card_challenge = resp['card_challenge'] self.card_challenge = resp['card_challenge']
self.sk = Scp02SessionKeys(resp['seq_counter'], self.card_keys) self.sk = Scp02SessionKeys(resp['seq_counter'], self.card_keys)
logger.debug(self.sk) log.debug(self.sk)
self._compute_cryptograms(self.card_challenge, self.host_challenge) self._compute_cryptograms(self.card_challenge, self.host_challenge)
if self.card_cryptogram != resp['card_cryptogram']: if self.card_cryptogram != resp['card_cryptogram']:
raise ValueError("card cryptogram doesn't match") raise ValueError("card cryptogram doesn't match")
@@ -311,7 +340,7 @@ class SCP02(SCP):
def _wrap_cmd_apdu(self, apdu: bytes, *args, **kwargs) -> bytes: def _wrap_cmd_apdu(self, apdu: bytes, *args, **kwargs) -> bytes:
"""Wrap Command APDU for SCP02: calculate MAC and encrypt.""" """Wrap Command APDU for SCP02: calculate MAC and encrypt."""
logger.debug("wrap_cmd_apdu(%s)", b2h(apdu)) log.debug("wrap_cmd_apdu(%s)", b2h(apdu))
if not self.do_cmac: if not self.do_cmac:
return apdu return apdu
@@ -338,10 +367,16 @@ class SCP02(SCP):
# CMAC on modified APDU # CMAC on modified APDU
mlc = lc + 8 mlc = lc + 8
clac = cla | CLA_SM clac = cla | CLA_SM
if mlc >= 256:
raise ValueError('Modified Lc (%u) would exceed maximum when appending 8 bytes of mac' % mlc)
mac = self.sk.calc_mac_1des(bytes([clac]) + apdu[1:4] + bytes([mlc]) + data) mac = self.sk.calc_mac_1des(bytes([clac]) + apdu[1:4] + bytes([mlc]) + data)
if self.do_cenc: if self.do_cenc:
padded_data = pad80(data, 8)
if len(padded_data) + 8 >= 256:
raise ValueError('Modified Lc (%u) would exceed maximum when appending padding and mac' %
(len(padded_data) + 8))
k = DES3.new(self.sk.enc, DES.MODE_CBC, b'\x00'*8) k = DES3.new(self.sk.enc, DES.MODE_CBC, b'\x00'*8)
data = k.encrypt(pad80(data, 8)) data = k.encrypt(padded_data)
lc = len(data) lc = len(data)
lc += 8 lc += 8
@@ -378,7 +413,7 @@ def scp03_key_derivation(constant: bytes, context: bytes, base_key: bytes, l: Op
if l is None: if l is None:
l = len(base_key) * 8 l = len(base_key) * 8
logger.debug("scp03_kdf(constant=%s, context=%s, base_key=%s, l=%u)", b2h(constant), b2h(context), b2h(base_key), l) log.debug("scp03_kdf(constant=%s, context=%s, base_key=%s, l=%u)", b2h(constant), b2h(context), b2h(base_key), l)
output_len = l // 8 output_len = l // 8
# SCP03 Section 4.1.5 defines a different parameter order than NIST SP 800-108, so we cannot use the # SCP03 Section 4.1.5 defines a different parameter order than NIST SP 800-108, so we cannot use the
# existing Cryptodome.Protocol.KDF.SP800_108_Counter function :( # existing Cryptodome.Protocol.KDF.SP800_108_Counter function :(
@@ -438,7 +473,7 @@ class Scp03SessionKeys:
"""Obtain the ICV value computed as described in 6.2.6. """Obtain the ICV value computed as described in 6.2.6.
This method has two modes: 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 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: if not is_response:
self.block_nr += 1 self.block_nr += 1
# The binary value of this number SHALL be left padded with zeroes to form a full block. # The binary value of this number SHALL be left padded with zeroes to form a full block.
@@ -451,7 +486,7 @@ class Scp03SessionKeys:
# This block SHALL be encrypted with S-ENC to produce the ICV for command encryption. # This block SHALL be encrypted with S-ENC to produce the ICV for command encryption.
cipher = AES.new(self.s_enc, AES.MODE_CBC, iv) cipher = AES.new(self.s_enc, AES.MODE_CBC, iv)
icv = cipher.encrypt(data) icv = cipher.encrypt(data)
logger.debug("_get_icv(data=%s, is_resp=%s) -> icv=%s", b2h(data), is_response, b2h(icv)) log.debug("_get_icv(data=%s, is_resp=%s) -> icv=%s", b2h(data), is_response, b2h(icv))
return icv return icv
# TODO: Resolve duplication with pySim.esim.bsp.BspAlgoCryptAES128 which provides pad80-wrapping # TODO: Resolve duplication with pySim.esim.bsp.BspAlgoCryptAES128 which provides pad80-wrapping
@@ -477,9 +512,13 @@ class SCP03(SCP):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.s_mode = kwargs.pop('s_mode', 8) self.s_mode = kwargs.pop('s_mode', 8)
self.overhead = self.s_mode
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@property
def mac_len(self) -> int:
# C-MAC truncated to 8 in S8 or 16 bytes in S16 mode
return self.s_mode
def dek_encrypt(self, plaintext:bytes) -> bytes: def dek_encrypt(self, plaintext:bytes) -> bytes:
cipher = AES.new(self.card_keys.dek, AES.MODE_CBC, b'\x00'*16) cipher = AES.new(self.card_keys.dek, AES.MODE_CBC, b'\x00'*16)
return cipher.encrypt(plaintext) return cipher.encrypt(plaintext)
@@ -489,12 +528,12 @@ class SCP03(SCP):
return cipher.decrypt(ciphertext) return cipher.decrypt(ciphertext)
def _compute_cryptograms(self): def _compute_cryptograms(self):
logger.debug("host_challenge(%s), card_challenge(%s)", b2h(self.host_challenge), b2h(self.card_challenge)) log.debug("host_challenge(%s), card_challenge(%s)", b2h(self.host_challenge), b2h(self.card_challenge))
# Card + Host Authentication Cryptogram: Section 6.2.2.2 + 6.2.2.3 # Card + Host Authentication Cryptogram: Section 6.2.2.2 + 6.2.2.3
context = self.host_challenge + self.card_challenge context = self.host_challenge + self.card_challenge
self.card_cryptogram = scp03_key_derivation(self.sk.DERIV_CONST_AUTH_CGRAM_CARD, context, self.sk.s_mac, l=self.s_mode*8) self.card_cryptogram = scp03_key_derivation(self.sk.DERIV_CONST_AUTH_CGRAM_CARD, context, self.sk.s_mac, l=self.s_mode*8)
self.host_cryptogram = scp03_key_derivation(self.sk.DERIV_CONST_AUTH_CGRAM_HOST, context, self.sk.s_mac, l=self.s_mode*8) self.host_cryptogram = scp03_key_derivation(self.sk.DERIV_CONST_AUTH_CGRAM_HOST, context, self.sk.s_mac, l=self.s_mode*8)
logger.debug("host_cryptogram(%s), card_cryptogram(%s)", b2h(self.host_cryptogram), b2h(self.card_cryptogram)) log.debug("host_cryptogram(%s), card_cryptogram(%s)", b2h(self.host_cryptogram), b2h(self.card_cryptogram))
def gen_init_update_apdu(self, host_challenge: Optional[bytes] = None) -> bytes: def gen_init_update_apdu(self, host_challenge: Optional[bytes] = None) -> bytes:
"""Generate INITIALIZE UPDATE APDU.""" """Generate INITIALIZE UPDATE APDU."""
@@ -514,7 +553,7 @@ class SCP03(SCP):
self.i_param = resp['i_param'] self.i_param = resp['i_param']
# derive session keys and compute cryptograms # derive session keys and compute cryptograms
self.sk = Scp03SessionKeys(self.card_keys, self.host_challenge, self.card_challenge) self.sk = Scp03SessionKeys(self.card_keys, self.host_challenge, self.card_challenge)
logger.debug(self.sk) log.debug(self.sk)
self._compute_cryptograms() self._compute_cryptograms()
# verify computed cryptogram matches received cryptogram # verify computed cryptogram matches received cryptogram
if self.card_cryptogram != resp['card_cryptogram']: if self.card_cryptogram != resp['card_cryptogram']:
@@ -529,7 +568,7 @@ class SCP03(SCP):
def _wrap_cmd_apdu(self, apdu: bytes, skip_cenc: bool = False) -> bytes: def _wrap_cmd_apdu(self, apdu: bytes, skip_cenc: bool = False) -> bytes:
"""Wrap Command APDU for SCP03: calculate MAC and encrypt.""" """Wrap Command APDU for SCP03: calculate MAC and encrypt."""
logger.debug("wrap_cmd_apdu(%s)", b2h(apdu)) log.debug("wrap_cmd_apdu(%s)", b2h(apdu))
if not self.do_cmac: if not self.do_cmac:
return apdu return apdu
@@ -584,7 +623,7 @@ class SCP03(SCP):
# status word: in this case only the status word shall be returned in the response. All status words # status word: in this case only the status word shall be returned in the response. All status words
# except '9000' and warning status words (i.e. '62xx' and '63xx') shall be interpreted as error status # except '9000' and warning status words (i.e. '62xx' and '63xx') shall be interpreted as error status
# words. # words.
logger.debug("unwrap_rsp_apdu(sw=%s, rsp_apdu=%s)", sw, rsp_apdu) log.debug("unwrap_rsp_apdu(sw=%s, rsp_apdu=%s)", sw, rsp_apdu)
if not self.do_rmac: if not self.do_rmac:
assert not self.do_renc assert not self.do_renc
return rsp_apdu return rsp_apdu
@@ -600,9 +639,9 @@ class SCP03(SCP):
if self.do_renc: if self.do_renc:
# decrypt response data # decrypt response data
decrypted = self.sk._decrypt(response_data) decrypted = self.sk._decrypt(response_data)
logger.debug("decrypted: %s", b2h(decrypted)) log.debug("decrypted: %s", b2h(decrypted))
# remove padding # remove padding
response_data = unpad80(decrypted) response_data = unpad80(decrypted)
logger.debug("response_data: %s", b2h(response_data)) log.debug("response_data: %s", b2h(response_data))
return response_data return response_data
+1
View File
@@ -91,6 +91,7 @@ class UiccSdInstallParams(TLV_IE_Collection, nested=[UiccScp, AcceptExtradAppsAn
# Key Usage: # Key Usage:
# KVN 0x01 .. 0x0F reserved for SCP80 # KVN 0x01 .. 0x0F reserved for SCP80
# KVN 0x81 .. 0x8f reserved for SCP81
# KVN 0x11 reserved for DAP specified in ETSI TS 102 226 # KVN 0x11 reserved for DAP specified in ETSI TS 102 226
# KVN 0x20 .. 0x2F reserved for SCP02 # KVN 0x20 .. 0x2F reserved for SCP02
# KID 0x01 = ENC; 0x02 = MAC; 0x03 = DEK # KID 0x01 = ENC; 0x02 = MAC; 0x03 = DEK
+2 -1
View File
@@ -152,7 +152,8 @@ class SimCard(SimCardBase):
return sw return sw
def update_smsp(self, smsp): def update_smsp(self, smsp):
data, sw = self._scc.update_record(EF['SMSP'], 1, rpad(smsp, 84)) print("using update_smsp")
data, sw = self._scc.update_record(EF['SMSP'], 1, smsp, leftpad=True)
return sw return sw
def update_ad(self, mnc=None, opmode=None, ofm=None, path=EF['AD']): def update_ad(self, mnc=None, opmode=None, ofm=None, path=EF['AD']):
+24 -5
View File
@@ -24,7 +24,16 @@
# #
import logging import logging
from cmd2 import style import enum
import cmd2
from packaging import version
if version.parse(cmd2.__version__) >= version.parse("3.0.0"):
from cmd2 import stylize as _stylize # pylint: disable=no-name-in-module
def _style(text, fg=None): # pylint: disable=function-redefined
return _stylize(text, fg) if fg else text
else: # cmd2>=2.6.2
from cmd2 import style as _style # pylint: disable=no-name-in-module
class _PySimLogHandler(logging.Handler): class _PySimLogHandler(logging.Handler):
def __init__(self, log_callback): def __init__(self, log_callback):
@@ -44,7 +53,7 @@ class PySimLogger:
""" """
LOG_FMTSTR = "%(levelname)s: %(message)s" LOG_FMTSTR = "%(levelname)s: %(message)s"
LOG_FMTSTR_VERBOSE = "%(module)s.%(lineno)d -- " + LOG_FMTSTR LOG_FMTSTR_VERBOSE = "%(name)s.%(lineno)d -- " + LOG_FMTSTR
__formatter = logging.Formatter(LOG_FMTSTR) __formatter = logging.Formatter(LOG_FMTSTR)
__formatter_verbose = logging.Formatter(LOG_FMTSTR_VERBOSE) __formatter_verbose = logging.Formatter(LOG_FMTSTR_VERBOSE)
@@ -63,7 +72,7 @@ class PySimLogger:
raise RuntimeError('static class, do not instantiate') raise RuntimeError('static class, do not instantiate')
@staticmethod @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 Set a print callback function and color scheme. This function call is optional. In case this method is not
called, default settings apply. called, default settings apply.
@@ -72,10 +81,20 @@ class PySimLogger:
have the following format: print_callback(message:str) have the following format: print_callback(message:str)
colors : An optional dict through which certain log levels can be assigned a color. colors : An optional dict through which certain log levels can be assigned a color.
(e.g. {logging.WARN: YELLOW}) (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.print_callback = print_callback
PySimLogger.colors = colors 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 @staticmethod
def set_verbose(verbose:bool = False): def set_verbose(verbose:bool = False):
""" """
@@ -108,10 +127,10 @@ class PySimLogger:
formatted_message = logging.Formatter.format(PySimLogger.__formatter, record) formatted_message = logging.Formatter.format(PySimLogger.__formatter, record)
color = PySimLogger.colors.get(record.levelno) color = PySimLogger.colors.get(record.levelno)
if color: if color:
if isinstance(color, str): if isinstance(color, str) and not isinstance(color, enum.Enum):
PySimLogger.print_callback(color + formatted_message + "\033[0m") PySimLogger.print_callback(color + formatted_message + "\033[0m")
else: else:
PySimLogger.print_callback(style(formatted_message, fg = color)) PySimLogger.print_callback(_style(formatted_message, fg = color))
else: else:
PySimLogger.print_callback(formatted_message) PySimLogger.print_callback(formatted_message)
+249 -12
View File
@@ -18,10 +18,12 @@
import zlib import zlib
import abc import abc
import struct import struct
from typing import Optional, Tuple from typing import Optional, Tuple, List, Union
from construct import Enum, Int8ub, Int16ub, Struct, BitsInteger, BitStruct from construct import ConstructError, Enum, Int8ub, Int16ub, Struct, BitsInteger, BitStruct
from construct import Flag, Padding, Switch, this, PrefixedArray, GreedyRange from construct import Flag, Padding, Switch, this, PrefixedArray, GreedyRange
from construct import Const, Prefixed, Select, Construct, SizeofError, stream_read, stream_write
from osmocom.construct import * from osmocom.construct import *
from osmocom.tlv import bertlv_encode_len
from osmocom.utils import b2h from osmocom.utils import b2h
from pySim.sms import UserDataHeader from pySim.sms import UserDataHeader
@@ -56,6 +58,217 @@ CompactRemoteResp = Struct('number_of_commands'/Int8ub,
'last_status_word'/HexAdapter(Bytes(2)), 'last_status_word'/HexAdapter(Bytes(2)),
'last_response_data'/HexAdapter(GreedyBytes)) 'last_response_data'/HexAdapter(GreedyBytes))
######################################################################
# Expanded Remote Application data format, ETSI TS 102 226 V19.0.0 (2025-11) Section 5.2
# 5.2.1 Expanded Remote command structure
# 5.2.1.1 C-APDU TLV
# 5.2.1.2 Immediate Action TLV
# 5.2.1.3 Error Action TLV
# 5.2.1.4 Script Chaining TLV
# 5.2.2 Expanded Remote response structure (tables 5.10 .. 5.16)
#
# definite length coding and indefinite length coding are supported.
#
# BER-TLV tag values from ETSI TS 101 220 V19.0.0 tables 7.18, 7.19, 7.20
# C-APDU / R-APDU ETSI TS 102 223 Section 8.35 + 8.36
# inside these the CR flag of the tag is 0 (TS 101 220 tables 7.19/7.20),
# so tag bytes are 22 and 23 and not A2/A3.
#
# This layer sits above the TS 102 225 security layer.
######################################################################
class BerTlvLength(Construct):
"""A definite-length BER-TLV length field used by the "expanded remote
application data format" from ISO/IEC 8825-1 referenced by TS 102 226 5.2
- short form (0..127 -> single octet)
- long form (128.. -> 0x8N followed by N length octets)
Indefinite length coding (first octet 0x80, TS 102 226 tables 5.2a/5.10a)
is omitted here because it is only recommended for HTTPS/CoAP transport, not SMS."""
def _parse(self, stream, context, path):
first = stream_read(stream, 1, path)[0]
if first < 0x80:
return first
num_octets = first & 0x7f
if num_octets == 0:
raise NotImplementedError('indefinite coding is not supported')
return int.from_bytes(stream_read(stream, num_octets, path), 'big')
def _build(self, obj, stream, context, path):
encoded = bertlv_encode_len(obj)
stream_write(stream, encoded, len(encoded), path)
return obj
def _sizeof(self, context, path):
raise SizeofError('BER-TLV length has a variable size?!')
BerTlvLen = BerTlvLength()
class _RApduValueAdapter(Adapter):
"""Split/join value of R-APDU COMPREHENSION-TLV TS 102 223 8.36
[R-APDU data (x-2 bytes)] SW1 SW2."""
def _decode(self, obj, context, path):
raw = bytes(obj)
return Container(response_data=b2h(raw[:-2]), status_word=b2h(raw[-2:]))
def _encode(self, obj, context, path):
return h2b(obj['response_data']) + h2b(obj['status_word'])
#### Command Scripting template TS 102 226 tables 5.2 / 5.2a, TS 101 220 tables 7.18/7.19
#
# The two TS 101 220 table 7.18 length codings use different template tags:
# - definite tag AA
# - indefinite AE
# In both codings the inner Command TLVs use definite length coding, only the
# surrounding template differs.
# TS 102 223 8.35
ExpandedC_APDU = Struct('_tag'/Const(b'\x22'),
'c_apdu'/Prefixed(BerTlvLen, HexAdapter(GreedyBytes)))
# shared by both length codings.
ExpandedCmdItems = GreedyRange(ExpandedC_APDU)
# TS 102 226 table 5.2: Command Scripting template, definite length coding only
ExpandedCmd = Struct('_tag'/Const(b'\xaa'),
'commands'/Prefixed(BerTlvLen, ExpandedCmdItems))
# TS 102 226 table 5.2a: indefinite length coding, 'AE 80 <C-APDU TLVs> 00 00'. GreedyRange
# stops at the first octet that is not a C-APDU tag, which is the end-of-contents marker.
ExpandedCmdIndef = Struct('_tag'/Const(b'\xae'), '_indef'/Const(b'\x80'),
'commands'/ExpandedCmdItems, '_eoc'/Const(b'\x00\x00'))
#### Response Scripting template TS 102 226 5.2.2, tables 5.10-5.16, TS 101 220 table 7.20
# TS 102 223 8.36
ExpandedR_APDU = Struct('_tag'/Const(b'\x23'),
'r_apdu'/Prefixed(BerTlvLen, _RApduValueAdapter(GreedyBytes)))
# TS 102 226 table 5.11
# Value is an integer per ISO/IEC 8825-1, likely just one octet.
ExpandedNumExecuted = Struct('_tag'/Const(b'\x80'),
'number_of_commands'/Prefixed(BerTlvLen, GreedyInteger()))
# TS 102 226 table 5.12
ExpandedBadFormat = Struct('_tag'/Const(b'\x90'),
'bad_format'/Prefixed(BerTlvLen,
Enum(Int8ub, unknown_tag=1, wrong_length=2, length_not_found=3)))
# TS 102 226 table 5.14
ExpandedImmediateActionResp = Struct('_tag'/Const(b'\x81'),
'immediate_action_response'/Prefixed(BerTlvLen,
Enum(Int8ub, suspension_error=1)))
# TS 102 226 table 5.16
ExpandedScriptChainingResp = Struct('_tag'/Const(b'\x83'),
'script_chaining_response'/Prefixed(BerTlvLen,
Enum(Int8ub, no_previous_script=1,
not_supported=2, unable_to_process=3)))
# response TLVs shared by the def and indef Response Scripting templates
ExpandedRespItems = GreedyRange(Select(ExpandedR_APDU,
ExpandedBadFormat,
ExpandedImmediateActionResp,
ExpandedScriptChainingResp))
# - starts with the "Number of executed command TLV objects" (table 5.10/5.13/5.15)
# - followed by a sequence of R-APDU TLVs
# - and/or one of the error # response TLVs
ExpandedRemoteResp = Struct('_tag'/Const(b'\xab'),
'body'/Prefixed(BerTlvLen, Struct(
'num_executed'/ExpandedNumExecuted,
'responses'/ExpandedRespItems)))
# TS 102 226 table 5.10a: indefinite length coding, no "number of executed" TLV
ExpandedRemoteRespIndef = Struct('_tag'/Const(b'\xaf'), '_indef'/Const(b'\x80'),
'responses'/ExpandedRespItems, '_eoc'/Const(b'\x00\x00'))
def encode_expanded_cmd(apdus: Union[bytes, List[bytes]],
length_coding: str = 'definite') -> bytes:
"""builds the Command Scripting template, TS 102 226 5.2.1
Args:
apdus: single C-APDU bytes or list of C-APDUs bytes. Each
C-APDU is wrapped into a C-APDU TLV- This function does not add
or modify Le.
length_coding: 'definite' (the default, tag 'AA', table 5.2) or
'indefinite' (tag 'AE', table 5.2a: 'AE 80 <cmd TLVs> 00 00').
Inner C-APDU TLVs use definite length coding in both cases.
Returns:
encoded Command Scripting template as bytes
"""
if isinstance(apdus, (bytes, bytearray)):
apdus = [apdus]
commands = [{'c_apdu': b2h(a)} for a in apdus]
if length_coding == 'definite':
return ExpandedCmd.build({'commands': commands})
if length_coding == 'indefinite':
return ExpandedCmdIndef.build({'commands': commands})
raise ValueError("Invalid length_coding: %r" % length_coding)
def decode_expanded_resp(data: bytes) -> Container:
"""Decode a Response Scripting template, TS 102 226 5.2.2 def and indef length
coding
returned Container has:
number_of_commands -- "number of executed command TLV objects" table 5.11
for definite coding. indefinite coding does not have
this TLV, so report the number of returned R-APDUs instead.
commands -- list of Containers, one per R-APDU TLV, each
with 'response_data' and 'status_word' hexstr
last_response_data -- response_data of the last R-APDU or ''
last_status_word -- status_word of the last R-APDU or None
truncated -- True if any R-APDU has SW 62F1.
5.2.1.1 states card sets that status when it had to truncate
C-APDU response data, and "this shall terminate the
processing of the command list".
so the response is short AND the remaining commands never ran.
bad_format -- error type of a trailing Bad format TLV if present
immediate_action_response -- Immediate Action Response TLV, if there was a suspension error
script_chaining_response -- Script Chaining Response TLV, if there was a chaining error
The 'last_response_data'/'last_status_word'/'number_of_commands' keys are compatible with
CompactRemoteResp so existing callers keep working."""
if isinstance(data, str):
data = h2b(data)
try:
if data[:1] == b'\xaf':
responses = ExpandedRemoteRespIndef.parse(data)['responses']
num_executed = None
else:
parsed = ExpandedRemoteResp.parse(data)
responses = parsed['body']['responses']
num_executed = parsed['body']['num_executed']['number_of_commands']
except ConstructError as e:
raise ValueError('malformed Response Scripting template: %s' % e) from e
commands = []
bad_format = None
immediate_action_response = None
script_chaining_response = None
for item in responses:
if 'r_apdu' in item:
commands.append(Container(response_data=item['r_apdu']['response_data'],
status_word=item['r_apdu']['status_word']))
elif 'bad_format' in item:
bad_format = item['bad_format']
elif 'immediate_action_response' in item:
immediate_action_response = item['immediate_action_response']
elif 'script_chaining_response' in item:
script_chaining_response = item['script_chaining_response']
# TS 102 226 5.2.1.1: 62F1 means response of a C-APDU was truncated, processing terminated
truncated = any(c['status_word'].lower() == '62f1' for c in commands)
return Container(number_of_commands=num_executed if num_executed is not None else len(commands),
commands=commands,
last_response_data=commands[-1]['response_data'] if commands else '',
last_status_word=commands[-1]['status_word'] if commands else None,
truncated=truncated,
bad_format=bad_format,
immediate_action_response=immediate_action_response,
script_chaining_response=script_chaining_response)
RC_CC_DS = Enum(BitsInteger(2), no_rc_cc_ds=0, rc=1, cc=2, ds=3) 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) 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) POR_REQ = Enum(BitsInteger(2), no_por=0, por_required=1, por_only_when_error=2)
@@ -149,13 +362,23 @@ class OtaDialect(abc.ABC):
raise ValueError("Invalid rc_cc_ds: %s" % spi['rc_cc_ds']) raise ValueError("Invalid rc_cc_ds: %s" % spi['rc_cc_ds'])
@abc.abstractmethod @abc.abstractmethod
def encode_cmd(self, otak: OtaKeyset, tar: bytes, spi: dict, apdu: bytes) -> bytes: def encode_cmd(self, otak: OtaKeyset, tar: bytes, spi: dict,
apdu: Union[bytes, List[bytes]], remote_format: str = 'compact') -> bytes:
"""Encode a command for a format.
remote_format:
'compact' TS 102 226 5.1, DEFAULT assumes apdus are opaque already-concatenated command strings
'expanded' TS 102 226 5.2 wraps a single C-APDU or list of C-APDUs in a Command Scripting template."""
pass pass
@abc.abstractmethod @abc.abstractmethod
def decode_resp(self, otak: OtaKeyset, spi: dict, apdu: bytes) -> (object, Optional["CompactRemoteResp"]): def decode_resp(self, otak: OtaKeyset, spi: dict, apdu: bytes,
"""Decode a response into a response packet and, if indicted (by a remote_format: str = 'compact') -> (object, Optional[object]):
response status of `"por_ok"`) a decoded response. """Decode response into response packet + a decoded response if por_ok.
remote_format:
'compact' -> DEFAULT TS 102 226 5.1.2 CompactRemoteResp2
'expanded' -> container returned by decode_expanded_resp(), TS 102 226 5.2.2
The response packet's common characteristics are not fully determined, The response packet's common characteristics are not fully determined,
and (so far) completely proprietary per dialect.""" and (so far) completely proprietary per dialect."""
@@ -221,12 +444,12 @@ class OtaAlgoCrypt(OtaAlgo, abc.ABC):
for subc in cls.__subclasses__(): for subc in cls.__subclasses__():
if subc.enum_name == otak.algo_crypt: if subc.enum_name == otak.algo_crypt:
return subc(otak) 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): class OtaAlgoAuth(OtaAlgo, abc.ABC):
def __init__(self, otak: OtaKeyset): def __init__(self, otak: OtaKeyset):
if self.enum_name != otak.algo_auth: 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) super().__init__(otak)
def sign(self, data:bytes) -> bytes: def sign(self, data:bytes) -> bytes:
@@ -335,7 +558,16 @@ class OtaDialectSms(OtaDialect):
'secured_data'/GreedyBytes) 'secured_data'/GreedyBytes)
hdr_construct = Struct('chl'/Int8ub, 'spi'/SPI, 'kic'/KIC, 'kid'/KID_CC, 'tar'/Bytes(3)) hdr_construct = Struct('chl'/Int8ub, 'spi'/SPI, 'kic'/KIC, 'kid'/KID_CC, 'tar'/Bytes(3))
def encode_cmd(self, otak: OtaKeyset, tar: bytes, spi: dict, apdu: bytes) -> bytes: def encode_cmd(self, otak: OtaKeyset, tar: bytes, spi: dict,
apdu: Union[bytes, List[bytes]], remote_format: str = 'compact') -> bytes:
# as above:
# expanded format is a Command Scripting template wrapping the C-APDU(s)
# compact format passes already concatenated command string
if remote_format == 'expanded':
apdu = encode_expanded_cmd(apdu)
elif remote_format != 'compact':
raise ValueError("Invalid remote_format: %s" % remote_format)
# length of signature in octets # length of signature in octets
len_sig = self._compute_sig_len(spi) len_sig = self._compute_sig_len(spi)
pad_cnt = 0 pad_cnt = 0
@@ -446,7 +678,10 @@ class OtaDialectSms(OtaDialect):
return hdr_dec['tar'], spi, apdu return hdr_dec['tar'], spi, apdu
def decode_resp(self, otak: OtaKeyset, spi: dict, data: bytes) -> ("OtaDialectSms.SmsResponsePacket", Optional["CompactRemoteResp"]): def decode_resp(self, otak: OtaKeyset, spi: dict, data: bytes,
remote_format: str = 'compact') -> ("OtaDialectSms.SmsResponsePacket", Optional[object]):
if remote_format not in ('compact', 'expanded'):
raise ValueError("Invalid remote_format: %s ?!" % remote_format)
if isinstance(data, str): if isinstance(data, str):
data = h2b(data) data = h2b(data)
# plain-text POR: 027100000e0ab000110000000000000001612f # plain-text POR: 027100000e0ab000110000000000000001612f
@@ -492,9 +727,11 @@ class OtaDialectSms(OtaDialect):
else: else:
raise OtaCheckError('Unknown por_rc_cc_ds: %s' % spi['por_rc_cc_ds']) raise OtaCheckError('Unknown por_rc_cc_ds: %s' % spi['por_rc_cc_ds'])
# TODO: ExpandedRemoteResponse according to TS 102 226 5.2.2
if res.response_status == 'por_ok' and len(res['secured_data']): if res.response_status == 'por_ok' and len(res['secured_data']):
dec = CompactRemoteResp.parse(res['secured_data']) if remote_format == 'expanded':
dec = decode_expanded_resp(res['secured_data'])
else:
dec = CompactRemoteResp.parse(res['secured_data'])
else: else:
dec = None dec = None
return (res, dec) return (res, dec)
+117 -5
View File
@@ -19,6 +19,7 @@
import typing import typing
import abc import abc
import logging
from bidict import bidict from bidict import bidict
from construct import Int8ub, Byte, Bit, Flag, BitsInteger from construct import Int8ub, Byte, Bit, Flag, BitsInteger
from construct import Struct, Enum, Tell, BitStruct, this, Padding from construct import Struct, Enum, Tell, BitStruct, this, Padding
@@ -28,6 +29,8 @@ from osmocom.utils import Hexstr, h2b, b2h
from smpp.pdu import pdu_types, operations from smpp.pdu import pdu_types, operations
logger = logging.getLogger(__name__)
BytesOrHex = typing.Union[Hexstr, bytes] BytesOrHex = typing.Union[Hexstr, bytes]
class UserDataHeader: class UserDataHeader:
@@ -60,6 +63,109 @@ class UserDataHeader:
return self._construct.build({'ies':self.ies, 'data':b''}) return self._construct.build({'ies':self.ies, 'data':b''})
class ConcatenatedSmsReassembler:
"""3GPP TS 23.040 section 9.2.3.24 concat multi part reassembly
A large user-data payload (e.g. a big OTA response packet) is split by the
sending entity into several SMS,
each carries a
- "concat short messages" IE in its UDH that identifies the set (ref num),
- total number of parts
- this parts seqno.
supports both:
IEI 0x00, section 9.2.3.24.1 8-bit ref form
IEI 0x08, section 9.2.3.24.8 the 16-bit ref form
Feed each received TP-User-Data (UDH + payload) to add() which
returns the reassembled TP-User-Data once all parts of the set have arrived,
or None as long as parts are still missing.
A non-concatenated SMS is returned unchanged,
just like one where the concat IE holds a reserved value:
TS 23.040 9.2.3.24.1 says
- both a total of zero
- a sequence number that is zero or greater than the total
that "the receiving entity shall ignore the whole IE",
we treat the message as a single, non-concatenated one and warn, not
as an error, so the caller does not die.
The reassembled TP-User-Data is built with a UDH that contains
the non-concat IEs seen in the parts, for example the the OTA "response packet"
indicator IE 0x71, followed by the concatenated payloads in sequence order,
so exactly the single-SMS form the sender would have produced for a payload that fits
into one SMS.
This allows convenient decoding by the normal single part path."""
# IEI: Concatenated short messages, 8-bit reference number
# (see 3GPP TS 23.040 section 9.2.3.24 and section 9.2.3.24.1)
CONCAT_8BIT = 0x00
# IEI: Concatenated short message, 16-bit reference number
# (see 3GPP TS 23.040 section 9.2.3.24 and section 9.2.3.24.8)
CONCAT_16BIT = 0x08
def __init__(self, max_sets: int = 8):
# keyed by (iei, ref, total): {'parts': {seq: payload}, 'header_ies'}, insertion ordered
self.sets = {}
self.max_sets = max_sets # incomplete sets kept, oldest is dropped beyond that
@classmethod
def _parse_concat_ie(cls, ies) -> typing.Optional[typing.Tuple[int, int, int, int]]:
"""Return (iei, ref, total, seq) of the concat IE, or None"""
for ie in ies:
if ie['iei'] == cls.CONCAT_8BIT and ie['length'] == 3:
v = ie['value']
return cls.CONCAT_8BIT, v[0], v[1], v[2]
if ie['iei'] == cls.CONCAT_16BIT and ie['length'] == 4:
v = ie['value']
return cls.CONCAT_16BIT, int.from_bytes(v[0:2], 'big'), v[2], v[3]
return None
def add(self, tpud: BytesOrHex) -> typing.Optional[bytes]:
"""Add one TP-User-Data.
Returns
- the reassembled TP-User-Data if set is complete or sms not multipart,
- else None"""
if isinstance(tpud, str):
tpud = h2b(tpud)
udh, payload = UserDataHeader.from_bytes(tpud)
concat = self._parse_concat_ie(udh.ies)
if concat is None:
return tpud
iei, ref, total, seq = concat
if total < 1 or seq < 1 or seq > total:
# TS 23.040 9.2.3.24.1 / 9.2.3.24.8, total zero or seqno zero / > total:
# Ignoring the IE means the message has no valid concat IE, which is a single part message.
# Better warn and hand it back rather than raise, so we don't kill the callers receive loop/session
logger.warning('Ignoring reserved concat IE (ref=%u total=%u seq=%u), treating the '
'message as non-concat', ref, total, seq)
return tpud
# TS 23.040 9.2.3.24.1 Total is constant in a set, refno only unique per IE form -> both set identity
# - full count = seqno 1..total is present
# - part disagreeing on the total ends up as set that cannot complete like set with missing parts
key = (iei, ref, total)
if key not in self.sets and len(self.sets) >= self.max_sets:
del self.sets[next(iter(self.sets))]
s = self.sets.setdefault(key, {'parts': {}, 'header_ies': []})
s['parts'][seq] = payload
# - remember the non concat IEs (OTA 0x71 indicator for example)
# - keep first seen occurrence of each IEI,
# so app IE present only in the first segment is preserved independent of arrival order
seen = {ie['iei'] for ie in s['header_ies']}
for ie in udh.ies:
if ie['iei'] in (self.CONCAT_8BIT, self.CONCAT_16BIT):
continue
if ie['iei'] not in seen:
s['header_ies'].append(ie)
seen.add(ie['iei'])
if len(s['parts']) < total:
return None
# all parts present -> reassemble in seq order
del self.sets[(iei, ref, total)]
body = b''.join(s['parts'][i] for i in range(1, total + 1))
header = UserDataHeader(s['header_ies']).to_bytes()
return header + body
def smpp_dcs_is_8bit(dcs: pdu_types.DataCoding) -> bool: def smpp_dcs_is_8bit(dcs: pdu_types.DataCoding) -> bool:
"""Determine if the given SMPP data coding scheme is 8-bit or not.""" """Determine if the given SMPP data coding scheme is 8-bit or not."""
if dcs == pdu_types.DataCoding(pdu_types.DataCodingScheme.DEFAULT, if dcs == pdu_types.DataCoding(pdu_types.DataCodingScheme.DEFAULT,
@@ -140,8 +246,8 @@ class AddressField:
def to_bytes(self) -> bytes: def to_bytes(self) -> bytes:
"""Encode the AddressField into the binary representation as used in T-PDU.""" """Encode the AddressField into the binary representation as used in T-PDU."""
num_digits = len(self.digits) num_digits = len(self.digits)
if num_digits % 2: # don't store the filler nibble or get_bytes() encodes it as digit and ends up too large
self.digits += 'f' digits = self.digits + 'f' if num_digits % 2 else self.digits
d = { d = {
'addr_len': num_digits, 'addr_len': num_digits,
'type_of_addr': { 'type_of_addr': {
@@ -149,7 +255,7 @@ class AddressField:
'type_of_number': self.ton, 'type_of_number': self.ton,
'numbering_plan_id': self.npi, 'numbering_plan_id': self.npi,
}, },
'digits': self.digits, 'digits': digits,
} }
return self._construct.build(d) return self._construct.build(d)
@@ -169,8 +275,14 @@ class SMS_TPDU(abc.ABC):
class SMS_DELIVER(SMS_TPDU): class SMS_DELIVER(SMS_TPDU):
"""Representation of a SMS-DELIVER T-PDU. This is the Network to MS/UE (downlink) direction.""" """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, flags_construct = BitStruct('tp_rp'/Flag,
Padding(1), 'tp_mms'/Flag, 'tp_mti'/BitsInteger(2)) 'tp_udhi'/Flag,
'tp_sri'/Flag,
Padding(1),
'tp_lp'/Flag,
'tp_mms'/Flag,
'tp_mti'/BitsInteger(2))
def __init__(self, **kwargs): def __init__(self, **kwargs):
kwargs['tp_mti'] = 0 kwargs['tp_mti'] = 0
super().__init__(**kwargs) super().__init__(**kwargs)
+61
View File
@@ -0,0 +1,61 @@
# coding=utf-8
"""Utilities / Functions related to sysmocom sysmoUSIM-SJS1 cards
(C) 2026 by sysmocom - s.f.m.c. GmbH
All Rights Reserved
Author: Eric Wild <ewild@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/>.
"""
from construct import Struct, Bytes, Flag
from osmocom.utils import *
from osmocom.construct import *
from pySim.filesystem import *
from pySim.runtime import RuntimeState
class EF_Ki(TransparentEF):
_test_de_encode = [
('000102030405060708090a0b0c0d0e0f',
{'key': h2b('000102030405060708090a0b0c0d0e0f')}),
]
def __init__(self, fid='00ff', name='EF.Ki'):
super().__init__(fid, name=name, desc='K/Ki authentication key', size=(16, 16))
self._construct = Struct('key'/Bytes(16))
class EF_OPc(TransparentEF):
_test_de_encode = [
('016ca53d7a0a804561646816d7b0c702fb',
{'use_opc_instead_of_op': True, 'op_opc': h2b('6ca53d7a0a804561646816d7b0c702fb')}),
]
def __init__(self, fid='00f7', name='EF.OPc'):
super().__init__(fid, name=name, desc='OP/OPc for milenage', size=(17, 17))
self._construct = Struct('use_opc_instead_of_op'/Flag, 'op_opc'/Bytes(16))
class SysmoUSIMSJS1(CardModel):
_atrs = ["3b9f96801fc78031a073be21136743200718000001a5"]
@classmethod
def add_files(cls, rs: RuntimeState):
"""Add sysmoUSIM-SJS1 specific files to given RuntimeState."""
# the key material lives in DF.GSM shared with ADF.USIM
if '7f20' in rs.mf.children:
rs.mf.children['7f20'].add_files([EF_Ki(), EF_OPc()])
+81 -20
View File
@@ -45,8 +45,10 @@ class ApduTracer:
class StdoutApduTracer(ApduTracer): class StdoutApduTracer(ApduTracer):
"""Minimalistic APDU tracer, printing commands to stdout.""" """Minimalistic APDU tracer, printing commands to stdout."""
def trace_response(self, cmd, sw, resp): def trace_command(self, cmd):
log.info("-> %s %s", cmd[:10], cmd[10:]) log.info("-> %s %s", cmd[:10], cmd[10:])
def trace_response(self, cmd, sw, resp):
log.info("<- %s: %s", sw, resp) log.info("<- %s: %s", sw, resp)
def trace_reset(self): def trace_reset(self):
@@ -70,10 +72,26 @@ class ProactiveHandler(abc.ABC):
raise NotImplementedError('No handler method for %s' % pcmd.decoded) raise NotImplementedError('No handler method for %s' % pcmd.decoded)
def prepare_response(self, pcmd: ProactiveCommand, general_result: str = 'performed_successfully'): def prepare_response(self, pcmd: ProactiveCommand, general_result: str = 'performed_successfully'):
# TERMINAL RESPONSE per ETSI TS 102 223 section 6.8: Command details (6.8.1) echoed from the
# command, Device identities (6.8.2) with source and destination swapped, Result (6.8.3).
# pcmd can be
# - decoded proactive command IE (.children contains CommandDetails/DeviceIdentities)
# - ProactiveCommand collection wrapper (empty .children).
# Normalise to the children obj, so both work:
# - handler that passes its decoded command
# - fallback path that passes collection
children = list(getattr(pcmd, 'children', None) or [])
if not any(isinstance(c, CommandDetails) for c in children):
decoded = getattr(pcmd, 'decoded', None)
if decoded is not None and decoded is not pcmd:
children = list(getattr(decoded, 'children', None) or [])
# The Command Details are echoed from the command that has been processed. # The Command Details are echoed from the command that has been processed.
(command_details,) = [c for c in pcmd.children if isinstance(c, CommandDetails)] command_details = next((c for c in children if isinstance(c, CommandDetails)), None)
# invert the device identities # invert the device identities
(command_dev_ids,) = [c for c in pcmd.children if isinstance(c, DeviceIdentities)] command_dev_ids = next((c for c in children if isinstance(c, DeviceIdentities)), None)
if command_details is None or command_dev_ids is None:
raise ValueError('failed to prepare TERMINAL RESPONSE: proactive command has no '
'CommandDetails/DeviceIdentities (%r)' % (pcmd,))
rsp_dev_ids = DeviceIdentities() rsp_dev_ids = DeviceIdentities()
rsp_dev_ids.from_dict({'device_identities': { rsp_dev_ids.from_dict({'device_identities': {
'dest_dev_id': command_dev_ids.decoded['source_dev_id'], 'dest_dev_id': command_dev_ids.decoded['source_dev_id'],
@@ -90,7 +108,7 @@ class LinkBase(abc.ABC):
self.sw_interpreter = sw_interpreter self.sw_interpreter = sw_interpreter
self.apdu_tracer = apdu_tracer self.apdu_tracer = apdu_tracer
self.proactive_handler = proactive_handler self.proactive_handler = proactive_handler
self.apdu_strict = False self.apdu_strict = True
@abc.abstractmethod @abc.abstractmethod
def __str__(self) -> str: def __str__(self) -> str:
@@ -301,24 +319,67 @@ class LinkBaseTpdu(LinkBase):
prev_tpdu = tpdu prev_tpdu = tpdu
data, sw = self.send_tpdu(tpdu) data, sw = self.send_tpdu(tpdu)
log.debug("T0: case #%u TPDU: %s => %s %s", case, tpdu, data or "(no data)", sw or "(no status word)")
if sw is None:
raise ValueError("no status word received")
# When we have sent the first APDU, the SW may indicate that there are response bytes # After sending the APDU/TPDU the UICC/eUICC or SIM may response with a status word that indicates that further
# available. There are two SWs commonly used for this 9fxx (sim) and 61xx (usim), where # TPDUs have to be sent in order to complete the task.
# xx is the number of response bytes available. if case == 4 or self.apdu_strict == False:
# See also: # In case the APDU is a case #4 APDU, the UICC/eUICC/SIM may indicate that there is response data
if sw is not None: # available which has to be retrieved using a GET RESPONSE command TPDU.
while (sw[0:2] in ['9f', '61', '62', '63']): #
# SW1=9F: 3GPP TS 51.011 9.4.1, Responses to commands which are correctly executed # ETSI TS 102 221, section 7.3.1.1.4 is very cleare about the fact that the GET RESPONSE mechanism
# SW1=61: ISO/IEC 7816-4, Table 5 — General meaning of the interindustry values of SW1-SW2 # shall only apply on case #4 APDUs but unfortunately it is impossible to distinguish between case #3
# SW1=62: ETSI TS 102 221 7.3.1.1.4 Clause 4b): 62xx, 63xx, 9xxx != 9000 # and case #4 when the APDU format is not strictly followed. In order to be able to detect case #4
tpdu_gr = tpdu[0:2] + 'c00000' + sw[2:4] # correctly the Le byte (usually 0x00) must be present, is often forgotten. To avoid problems with
# legacy scripts that use raw APDU strings, we will still loosely apply GET RESPONSE based on what
# the status word indicates. Unless the user explicitly enables the strict mode (set apdu_strict true)
#
# The dummy GET RESPONSE of clause 4b (see below) is one shot: it turns a warning SW into the 61xx
# that announces the response length. It is only ever a valid reaction to the SW returned for the
# _command_ TPDU. Once a response has been fetched there is nothing left to announce, so a warning
# SW is the final result of the command and has to be passed on to the caller unmodified.
#
# This matters because the 62xx/63xx range is not exclusive to ETSI TS 102 221.
# GPC v2.3.1 section 11.4.3.2 table 11-38 GP GET STATUS (80 F2) answers
# 6310 "more data available", meaning "reissue with P2 bit 1 set" as per section 11.4.2.2 table 11-34
# rather than "response data is waiting". Trying a random GET RESPONSE at that point
# makes the card answer 6982 and tears down the whole SCP session and following commands fail with 6985.
dummy_gr_allowed = not data
while True:
if sw in ['9000', '9100']:
# A status word of 9000 (or 9100 in case there is pending data from a proactive SIM command)
# indicates that either no response data was returnd or all response data has been retrieved
# successfully. We may discontinue the processing at this point.
break;
if sw[0:2] in ['61', '9f']:
# A status word of 61xx or 9fxx indicates that there is (still) response data available. We
# send a GET RESPONSE command with the length value indicated in the second byte of the status
# word. (see also ETSI TS 102 221, section 7.3.1.1.4, clause 4a and 3GPP TS 51.011 9.4.1 and
# ISO/IEC 7816-4, Table 5)
le_gr = sw[2:4]
elif sw[0:2] in ['62', '63'] and dummy_gr_allowed:
# There are corner cases (status word is 62xx or 63xx) where the UICC/eUICC/SIM asks us
# to send a dummy GET RESPONSE command. We send a GET RESPONSE command with a length of 0.
# (see also ETSI TS 102 221, section 7.3.1.1.4, clause 4b and ETSI TS 151 011, section 9.4.1)
le_gr = '00'
else:
# A status word other then the ones covered by the above logic may indicate an error. In this
# case we will discontinue the processing as well.
# (see also ETSI TS 102 221, section 7.3.1.1.4, clause 4c)
break
tpdu_gr = tpdu[0:2] + 'c00000' + le_gr
prev_tpdu = tpdu_gr prev_tpdu = tpdu_gr
d, sw = self.send_tpdu(tpdu_gr) data_gr, sw = self.send_tpdu(tpdu_gr)
data += d log.debug("T0: GET RESPONSE TPDU: %s => %s %s", tpdu_gr, data_gr or "(no data)", sw or "(no status word)")
if sw[0:2] == '6c': data += data_gr
# SW1=6C: ETSI TS 102 221 Table 7.1: Procedure byte coding dummy_gr_allowed = False
tpdu_gr = prev_tpdu[0:8] + sw[2:4] if sw[0:2] == '6c':
data, sw = self.send_tpdu(tpdu_gr) # SW1=6C: ETSI TS 102 221 Table 7.1: Procedure byte coding
tpdu_gr = prev_tpdu[0:8] + sw[2:4]
data, sw = self.send_tpdu(tpdu_gr)
log.debug("T0: repated case #%u TPDU: %s => %s %s", case, tpdu_gr, data or "(no data)", sw or "(no status word)")
return data, sw return data, sw
+12 -9
View File
@@ -26,6 +26,7 @@ from smartcard.CardRequest import CardRequest
from smartcard.Exceptions import NoCardException, CardRequestTimeoutException, CardConnectionException from smartcard.Exceptions import NoCardException, CardRequestTimeoutException, CardConnectionException
from smartcard.System import readers from smartcard.System import readers
from smartcard.ExclusiveConnectCardConnection import ExclusiveConnectCardConnection from smartcard.ExclusiveConnectCardConnection import ExclusiveConnectCardConnection
from smartcard.ATR import ATR
from osmocom.utils import h2i, i2h, Hexstr from osmocom.utils import h2i, i2h, Hexstr
@@ -80,23 +81,25 @@ class PcscSimLink(LinkBaseTpdu):
def connect(self): def connect(self):
try: try:
# To avoid leakage of resources, make sure the reader # To avoid leakage of resources, make sure the reader is disconnected
# is disconnected
self.disconnect() self.disconnect()
# Make card connection and select a suitable communication protocol # 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() self._con.connect()
supported_protocols = self._con.getProtocol(); atr = ATR(self._con.getATR())
self.disconnect() if atr.isT0Supported():
if (supported_protocols & CardConnection.T0_protocol): self._con.setProtocol(CardConnection.T0_protocol)
protocol = CardConnection.T0_protocol
self.set_tpdu_format(0) self.set_tpdu_format(0)
elif (supported_protocols & CardConnection.T1_protocol): elif atr.isT1Supported():
protocol = CardConnection.T1_protocol self._con.setProtocol(CardConnection.T1_protocol)
self.set_tpdu_format(1) self.set_tpdu_format(1)
else: else:
raise ReaderError('Unsupported card protocol') raise ReaderError('Unsupported card protocol')
self._con.connect(protocol)
except CardConnectionException as exc: except CardConnectionException as exc:
raise ProtocolError() from exc raise ProtocolError() from exc
except NoCardException as exc: except NoCardException as exc:
+36 -11
View File
@@ -17,6 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
""" """
from bidict import bidict from bidict import bidict
import copy
from construct import Select, Const, Bit, Struct, Int16ub, FlagsEnum, GreedyString, ValidationError from construct import Select, Const, Bit, Struct, Int16ub, FlagsEnum, GreedyString, ValidationError
from construct import Optional as COptional, Computed from construct import Optional as COptional, Computed
@@ -335,6 +336,8 @@ class TerminalCapability(BER_TLV_IE, tag=0xa9, nested=[TerminalPowerSupply, Exte
# ETSI TS 102 221 Section 9.2.7 + ISO7816-4 9.3.3/9.3.4 # ETSI TS 102 221 Section 9.2.7 + ISO7816-4 9.3.3/9.3.4
class _AM_DO_DF(DataObject): class _AM_DO_DF(DataObject):
"""ISO7816-4:2005 5.4.3.1 Table 16"""
def __init__(self): def __init__(self):
super().__init__('access_mode', 'Access Mode', tag=0x80) super().__init__('access_mode', 'Access Mode', tag=0x80)
@@ -381,7 +384,7 @@ class _AM_DO_DF(DataObject):
class _AM_DO_EF(DataObject): class _AM_DO_EF(DataObject):
"""ISO7816-4 9.3.2 Table 18 + 9.3.3.1 Table 31""" """ISO7816-4:2005 5.4.3.1 Table 17"""
def __init__(self): def __init__(self):
super().__init__('access_mode', 'Access Mode', tag=0x80) super().__init__('access_mode', 'Access Mode', tag=0x80)
@@ -429,7 +432,7 @@ class _AM_DO_EF(DataObject):
class _AM_DO_CHDR(DataObject): class _AM_DO_CHDR(DataObject):
"""Command Header Access Mode DO according to ISO 7816-4 Table 32.""" """Command Header Access Mode DO according to ISO 7816-4:2005 5.4.3.2 Table 22."""
def __init__(self, tag): def __init__(self, tag):
super().__init__('command_header', 'Command Header Description', tag=tag) super().__init__('command_header', 'Command Header Description', tag=tag)
@@ -543,8 +546,9 @@ class CRT_DO(DataObject):
pin = pin_names.inverse[self.decoded] pin = pin_names.inverse[self.decoded]
return b'\x83\x01' + pin.to_bytes(1, 'big') + b'\x95\x01\x08' return b'\x83\x01' + pin.to_bytes(1, 'big') + b'\x95\x01\x08'
# ISO7816-4 9.3.3 Table 33
class SecCondByte_DO(DataObject): class SecCondByte_DO(DataObject):
"""ISO7816-4:2005 5.4.3.1 Table 20"""
def __init__(self, tag=0x9d): def __init__(self, tag=0x9d):
super().__init__('security_condition_byte', tag=tag) super().__init__('security_condition_byte', tag=tag)
@@ -732,36 +736,57 @@ class EF_ARR(LinFixedEF):
raise ValueError raise ValueError
return by_mode return by_mode
@staticmethod
def __get_do_sequence(decode_for_df : bool = False):
if decode_for_df:
return DataObjectSequence('arr', sequence=[AM_DO_DF, SC_DO])
else:
return DataObjectSequence('arr', sequence=[AM_DO_EF, SC_DO])
def _decode_record_bin(self, raw_bin_data, **kwargs): def _decode_record_bin(self, raw_bin_data, **kwargs):
# we can only guess if we should decode for EF or DF here :( # we can only guess if we should decode for EF or DF here, but our caller may
arr_seq = DataObjectSequence('arr', sequence=[AM_DO_EF, SC_DO]) # be able to pass us a hint:
arr_seq = self.__get_do_sequence(kwargs.get('decode_for_df', False))
dec = arr_seq.decode_multi(raw_bin_data) dec = arr_seq.decode_multi(raw_bin_data)
# we cannot pass the result through flatten() here, as we don't have a related # we cannot pass the result through flatten() here, as we don't have a related
# 'un-flattening' decoder, and hence would be unable to encode :( # 'un-flattening' decoder, and hence would be unable to encode :(
return dec[0] return dec[0]
def _encode_record_bin(self, in_json, **kwargs): def _encode_record_bin(self, in_json, **kwargs):
# we can only guess if we should decode for EF or DF here :( # we can only guess if we should decode for EF or DF here, but our caller may
arr_seq = DataObjectSequence('arr', sequence=[AM_DO_EF, SC_DO]) # be able to pass us a hint:
arr_seq = self.__get_do_sequence(kwargs.get('encode_for_df', False))
return arr_seq.encode_multi(in_json) return arr_seq.encode_multi(in_json)
@with_default_category('File-Specific Commands') @with_default_category('File-Specific Commands')
class AddlShellCommands(CommandSet): class AddlShellCommands(CommandSet):
@cmd2.with_argparser(LinFixedEF.ShellCommands.read_rec_dec_parser) read_arr_argparser = copy.deepcopy(LinFixedEF.ShellCommands.read_rec_dec_parser)
read_arr_argparser.add_argument('--decode-for-df', action='store_true',
help='Decode EF.ARR record as if used by a DF (default: EF)')
@cmd2.with_argparser(read_arr_argparser)
def do_read_arr_record(self, opts): def do_read_arr_record(self, opts):
"""Read one EF.ARR record in flattened, human-friendly form.""" """Read one EF.ARR record in flattened, human-friendly form."""
(data, _sw) = self._cmd.lchan.read_record_dec(opts.RECORD_NR) (hexdata, _sw) = self._cmd.lchan.read_record(opts.RECORD_NR)
data = self._cmd.lchan.selected_file._decode_record_bin(h2b(hexdata),
decode_for_df = opts.decode_for_df)
data = self._cmd.lchan.selected_file.flatten(data) data = self._cmd.lchan.selected_file.flatten(data)
self._cmd.poutput_json(data, opts.oneline) self._cmd.poutput_json(data, opts.oneline)
@cmd2.with_argparser(LinFixedEF.ShellCommands.read_recs_dec_parser) read_arrs_argparser = copy.deepcopy(LinFixedEF.ShellCommands.read_recs_dec_parser)
read_arrs_argparser.add_argument('--decode-for-df', action='store_true',
help='Decode EF.ARR records as if used by a DF (default: EF)')
@cmd2.with_argparser(read_arrs_argparser)
def do_read_arr_records(self, opts): def do_read_arr_records(self, opts):
"""Read + decode all EF.ARR records in flattened, human-friendly form.""" """Read + decode all EF.ARR records in flattened, human-friendly form."""
num_of_rec = self._cmd.lchan.selected_file_num_of_rec() num_of_rec = self._cmd.lchan.selected_file_num_of_rec()
# collect all results in list so they are rendered as JSON list when printing # collect all results in list so they are rendered as JSON list when printing
data_list = [] data_list = []
for recnr in range(1, 1 + num_of_rec): for recnr in range(1, 1 + num_of_rec):
(data, _sw) = self._cmd.lchan.read_record_dec(recnr) (hexdata, _sw) = self._cmd.lchan.read_record(recnr)
data = self._cmd.lchan.selected_file._decode_record_bin(h2b(hexdata),
decode_for_df = opts.decode_for_df)
data = self._cmd.lchan.selected_file.flatten(data) data = self._cmd.lchan.selected_file.flatten(data)
data_list.append(data) data_list.append(data)
self._cmd.poutput_json(data_list, opts.oneline) self._cmd.poutput_json(data_list, opts.oneline)
+13 -5
View File
@@ -285,6 +285,14 @@ class EF_SUCI_Calc_Info(TransparentEF):
{"hnet_pubkey_identifier": 11, "hnet_pubkey": {"hnet_pubkey_identifier": 11, "hnet_pubkey":
h2b("d1bc365f4997d17ce4374e72181431cbfeba9e1b98d7618f79d48561b144672a")}]} ), h2b("d1bc365f4997d17ce4374e72181431cbfeba9e1b98d7618f79d48561b144672a")}]} ),
] ]
_test_decode = [
( 'A000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF',
{"prot_scheme_id_list": [],
"hnet_pubkey_list": []} ),
( 'A000A100FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF',
{"prot_scheme_id_list": [],
"hnet_pubkey_list": []} ),
]
# 3GPP TS 31.102 Section 4.4.11.8 # 3GPP TS 31.102 Section 4.4.11.8
class ProtSchemeIdList(BER_TLV_IE, tag=0xa0): class ProtSchemeIdList(BER_TLV_IE, tag=0xa0):
# FIXME: 3GPP TS 24.501 Protection Scheme Identifier # FIXME: 3GPP TS 24.501 Protection Scheme Identifier
@@ -327,7 +335,7 @@ class EF_SUCI_Calc_Info(TransparentEF):
"""conversion method to generate list of {hnet_pubkey_identifier, hnet_pubkey} dicts """conversion method to generate list of {hnet_pubkey_identifier, hnet_pubkey} dicts
from flat [{hnet_pubkey_identifier: }, {net_pubkey: }, ...] list""" from flat [{hnet_pubkey_identifier: }, {net_pubkey: }, ...] list"""
out = [] out = []
while len(l): while l:
a = l.pop(0) a = l.pop(0)
b = l.pop(0) b = l.pop(0)
z = {**a, **b} z = {**a, **b}
@@ -389,7 +397,7 @@ class EF_SUCI_Calc_Info(TransparentEF):
# remaining data holds Home Network Public Key Data Object # remaining data holds Home Network Public Key Data Object
hpkl = EF_SUCI_Calc_Info.HnetPubkeyList() hpkl = EF_SUCI_Calc_Info.HnetPubkeyList()
hpkl.from_tlv(in_bytes[pos:]) hpkl.from_tlv(in_bytes[pos:])
hnet_pubkey_list = self._compact_pubkey_list(hpkl.to_dict()['hnet_pubkey_list']) hnet_pubkey_list = self._compact_pubkey_list(hpkl.to_dict()['hnet_pubkey_list'] or [])
return { return {
'prot_scheme_id_list': prot_scheme_id_list, 'prot_scheme_id_list': prot_scheme_id_list,
@@ -1058,7 +1066,7 @@ class EF_OCSGL(LinFixedEF):
# TS 31.102 Section 4.4.11.2 (Rel 15) # TS 31.102 Section 4.4.11.2 (Rel 15)
class EF_5GS3GPPLOCI(TransparentEF): class EF_5GS3GPPLOCI(TransparentEF):
def __init__(self, fid='4f01', sfid=0x01, name='EF.5GS3GPPLOCI', size=(20, 20), 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) super().__init__(fid, sfid=sfid, name=name, desc=desc, size=size, **kwargs)
upd_status_constr = Enum( upd_status_constr = Enum(
Byte, updated=0, not_updated=1, roaming_not_allowed=2) Byte, updated=0, not_updated=1, roaming_not_allowed=2)
@@ -1326,7 +1334,7 @@ class EF_5G_PROSE_UIR(TransparentEF):
pass pass
class FiveGDdnmfCtfAddrForUploading(BER_TLV_IE, tag=0x97): class FiveGDdnmfCtfAddrForUploading(BER_TLV_IE, tag=0x97):
pass pass
class ProSeConfigDataForUeToNetworkRelayUE(BER_TLV_IE, tag=0xa0, class ProSeConfigDataForUsageInfoReporting(BER_TLV_IE, tag=0xa0,
nested=[EF_5G_PROSE_DD.ValidityTimer, nested=[EF_5G_PROSE_DD.ValidityTimer,
CollectionPeriod, ReportingWindow, CollectionPeriod, ReportingWindow,
ReportingIndicators, ReportingIndicators,
@@ -1336,7 +1344,7 @@ class EF_5G_PROSE_UIR(TransparentEF):
desc='5G ProSe configuration data for usage information reporting', **kwargs): desc='5G ProSe configuration data for usage information reporting', **kwargs):
super().__init__(fid, sfid=sfid, name=name, desc=desc, **kwargs) super().__init__(fid, sfid=sfid, name=name, desc=desc, **kwargs)
# contains TLV structure despite being TransparentEF, not BER-TLV ?!? # 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) # TS 31.102 Section 4.4.13.8 (Rel 18)
class EF_5G_PROSE_U2URU(TransparentEF): class EF_5G_PROSE_U2URU(TransparentEF):
+98 -23
View File
@@ -251,6 +251,16 @@ class EF_SMSP(LinFixedEF):
"numbering_plan_id": "isdn_e164" }, "numbering_plan_id": "isdn_e164" },
"call_number": "4915790109999" }, "call_number": "4915790109999" },
"tp_pid": b"\x00", "tp_dcs": b"\x00", "tp_vp_minutes": 4320 } ), "tp_pid": b"\x00", "tp_dcs": b"\x00", "tp_vp_minutes": 4320 } ),
( 'e1ffffffffffffffffffffffff0891945197109099f9ffffff0000a9',
{ "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": 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', ( '454e6574776f726b73fffffffffffffff1ffffffffffffffffffffffffffffffffffffffffffffffff0000a7',
{ "alpha_id": "ENetworks", "parameter_indicators": { "tp_dest_addr": False, "tp_sc_addr": True, { "alpha_id": "ENetworks", "parameter_indicators": { "tp_dest_addr": False, "tp_sc_addr": True,
"tp_pid": True, "tp_dcs": True, "tp_vp": False }, "tp_pid": True, "tp_dcs": True, "tp_vp": False },
@@ -261,6 +271,26 @@ class EF_SMSP(LinFixedEF):
"numbering_plan_id": "reserved_for_extension" }, "numbering_plan_id": "reserved_for_extension" },
"call_number": "" }, "call_number": "" },
"tp_pid": b"\x00", "tp_dcs": b"\x00", "tp_vp_minutes": 1440 } ), "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 _test_no_pad = True
class ValidityPeriodAdapter(Adapter): class ValidityPeriodAdapter(Adapter):
@@ -289,17 +319,30 @@ class EF_SMSP(LinFixedEF):
@staticmethod @staticmethod
def sc_addr_len(ctx): def sc_addr_len(ctx):
"""Compute the length field for an address field (like TP-DestAddr or TP-ScAddr).""" """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: if not hasattr(ctx, 'call_number') or len(ctx.call_number) == 0:
return 0xff return 0xff
else: else:
# octets required for the call_number + one octet for ton_npi
return bytes_for_nibbles(len(ctx.call_number)) + 1 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): 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) super().__init__(fid, sfid=sfid, name=name, desc=desc, rec_len=(28, None), **kwargs)
ScAddr = Struct('length'/Rebuild(Int8ub, lambda ctx: EF_SMSP.sc_addr_len(ctx)), ScAddr = Struct('length'/Rebuild(Int8ub, lambda ctx: EF_SMSP.sc_addr_len(ctx)),
'ton_npi'/TonNpi, 'call_number'/PaddedBcdAdapter(Rpad(Bytes(10)))) 'ton_npi'/TonNpi, 'call_number'/PaddedBcdAdapter(Rpad(Bytes(10))))
self._construct = Struct('alpha_id'/COptional(GsmOrUcs2Adapter(Rpad(Bytes(this._.total_len-28)))), DestAddr = Struct('length'/Rebuild(Int8ub, lambda ctx: EF_SMSP.dest_addr_len(ctx)),
'ton_npi'/TonNpi, 'call_number'/PaddedBcdAdapter(Rpad(Bytes(10))))
# (see comment below)
self._construct = Struct('alpha_id'/GsmOrUcs2Adapter(Rpad(Bytes(this._.total_len-28))),
'parameter_indicators'/InvertAdapter(BitStruct( 'parameter_indicators'/InvertAdapter(BitStruct(
Const(7, BitsInteger(3)), Const(7, BitsInteger(3)),
'tp_vp'/Flag, 'tp_vp'/Flag,
@@ -307,13 +350,31 @@ class EF_SMSP(LinFixedEF):
'tp_pid'/Flag, 'tp_pid'/Flag,
'tp_sc_addr'/Flag, 'tp_sc_addr'/Flag,
'tp_dest_addr'/Flag)), 'tp_dest_addr'/Flag)),
'tp_dest_addr'/ScAddr, 'tp_dest_addr'/DestAddr,
'tp_sc_addr'/ScAddr, 'tp_sc_addr'/ScAddr,
'tp_pid'/Bytes(1), 'tp_pid'/Bytes(1),
'tp_dcs'/Bytes(1), 'tp_dcs'/Bytes(1),
'tp_vp_minutes'/EF_SMSP.ValidityPeriodAdapter(Byte)) 'tp_vp_minutes'/EF_SMSP.ValidityPeriodAdapter(Byte))
# Ensure 'alpha_id' is always present
def encode_record_hex(self, abstract_data: dict, record_nr: int, total_len: int = None) -> str:
# Problem: TS 51.011 Section 10.5.6 describes the 'alpha_id' field as optional. However, this is only true
# at the time when the record length of the file is set up in the file system. A card manufacturer may decide
# to remove the field by setting the record length to 28. Likewise, the card manaufacturer may also decide to
# set the field to a distinct length by setting the record length to a value greater than 28 (e.g. 14 bytes
# 'alpha_id' + 28 bytes). Due to the fixed nature of the record length, this eventually means that in practice
# 'alpha_id' is a mandatory field with a fixed length.
#
# Due to the problematic specification of 'alpha_id' as a pseudo-optional field at the beginning of a
# fixed-size memory, the construct definition in self._construct has been incorrectly implemented and the field
# has been marked as COptional. We may correct the problem by removing COptional. But to maintain compatibility,
# we then have to ensure that in case the field is not provided (None), it is set to an empty string ('').
#
# See also ts_31_102.py, class EF_OCI for a correct example.
if abstract_data['alpha_id'] is None:
abstract_data['alpha_id'] = ''
return super().encode_record_hex(abstract_data, record_nr, total_len)
# TS 51.011 Section 10.5.7 # TS 51.011 Section 10.5.7
class EF_SMSS(TransparentEF): class EF_SMSS(TransparentEF):
class MemCapAdapter(Adapter): class MemCapAdapter(Adapter):
@@ -389,7 +450,7 @@ class DF_TELECOM(CardDF):
# TS 51.011 Section 10.3.1 # TS 51.011 Section 10.3.1
class EF_LP(TransRecEF): class EF_LP(TransRecEF):
_test_de_encode = [ _test_de_encode = [
( "24", "24"), ( "24", ["24"] ),
] ]
def __init__(self, fid='6f05', sfid=None, name='EF.LP', size=(1, None), rec_len=1, def __init__(self, fid='6f05', sfid=None, name='EF.LP', size=(1, None), rec_len=1,
desc='Language Preference'): desc='Language Preference'):
@@ -446,8 +507,8 @@ class EF_IMSI(TransparentEF):
# TS 51.011 Section 10.3.4 # TS 51.011 Section 10.3.4
class EF_PLMNsel(TransRecEF): class EF_PLMNsel(TransRecEF):
_test_de_encode = [ _test_de_encode = [
( "22F860", { "mcc": "228", "mnc": "06" } ), ( "22F860", [{ "mcc": "228", "mnc": "06" }] ),
( "330420", { "mcc": "334", "mnc": "020" } ), ( "330420", [{ "mcc": "334", "mnc": "020" }] ),
] ]
def __init__(self, fid='6f30', sfid=None, name='EF.PLMNsel', desc='PLMN selector', def __init__(self, fid='6f30', sfid=None, name='EF.PLMNsel', desc='PLMN selector',
size=(24, None), rec_len=3, **kwargs): size=(24, None), rec_len=3, **kwargs):
@@ -661,7 +722,7 @@ class EF_AD(TransparentEF):
# TS 51.011 Section 10.3.20 / 10.3.22 # TS 51.011 Section 10.3.20 / 10.3.22
class EF_VGCS(TransRecEF): class EF_VGCS(TransRecEF):
_test_de_encode = [ _test_de_encode = [
( "92f9ffff", "299" ), ( "92f9ffff", ["299"] ),
] ]
def __init__(self, fid='6fb1', sfid=None, name='EF.VGCS', size=(4, 200), rec_len=4, def __init__(self, fid='6fb1', sfid=None, name='EF.VGCS', size=(4, 200), rec_len=4,
desc='Voice Group Call Service', **kwargs): desc='Voice Group Call Service', **kwargs):
@@ -797,9 +858,9 @@ class EF_LOCIGPRS(TransparentEF):
# TS 51.011 Section 10.3.35..37 # TS 51.011 Section 10.3.35..37
class EF_xPLMNwAcT(TransRecEF): class EF_xPLMNwAcT(TransRecEF):
_test_de_encode = [ _test_de_encode = [
( '62F2104000', { "mcc": "262", "mnc": "01", "act": [ "E-UTRAN NB-S1", "E-UTRAN WB-S1" ] } ), ( '62F2104000', [{ "mcc": "262", "mnc": "01", "act": [ "E-UTRAN NB-S1", "E-UTRAN WB-S1" ] }] ),
( '62F2108000', { "mcc": "262", "mnc": "01", "act": [ "UTRAN" ] } ), ( '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'] } ), ( '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): 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) super().__init__(fid, sfid=sfid, name=name, desc=desc, size=size, rec_len=rec_len, **kwargs)
@@ -1034,9 +1095,10 @@ class EF_ICCID(TransparentEF):
# TS 102 221 Section 13.3 / TS 31.101 Section 13 / TS 51.011 Section 10.1.2 # TS 102 221 Section 13.3 / TS 31.101 Section 13 / TS 51.011 Section 10.1.2
class EF_PL(TransRecEF): class EF_PL(TransRecEF):
_test_de_encode = [ _test_de_encode = [
( '6465', "de" ), ( '6465', ["de"] ),
( '656e', "en" ), ( '656e', ["en"] ),
( 'ffff', None ), ( 'ffff', [None] ),
( '656e64657275ffffffff', ["en", "de", "ru", None, None] ),
] ]
def __init__(self, fid='2f05', sfid=0x05, name='EF.PL', desc='Preferred Languages'): def __init__(self, fid='2f05', sfid=0x05, name='EF.PL', desc='Preferred Languages'):
@@ -1117,8 +1179,8 @@ class DF_GSM(CardDF):
EF_MBI(), EF_MBI(),
EF_MWIS(), EF_MWIS(),
EF_CFIS(), EF_CFIS(),
EF_EXT('6fc8', None, 'EF.EXT6', desc='Externsion6 (MBDN)'), EF_EXT('6fc8', None, 'EF.EXT6', desc='Extension6 (MBDN)'),
EF_EXT('6fcc', None, 'EF.EXT7', desc='Externsion7 (CFIS)'), EF_EXT('6fcc', None, 'EF.EXT7', desc='Extension7 (CFIS)'),
EF_SPDI(), EF_SPDI(),
EF_MMSN(), EF_MMSN(),
EF_EXT('6fcf', None, 'EF.EXT8', desc='Extension8 (MMSN)'), EF_EXT('6fcf', None, 'EF.EXT8', desc='Extension8 (MMSN)'),
@@ -1201,9 +1263,11 @@ class CardProfileSIM(CardProfile):
@staticmethod @staticmethod
def decode_select_response(resp_hex: str) -> object: def decode_select_response(resp_hex: str) -> object:
# we try to build something that resembles a dict resulting from the TLV decoder """
# of TS 102.221 (FcpTemplate), so that higher-level code only has to deal with one Decode the select response to a dict representation, similar to the one of TS 102.221 (see ts_102_221.py,
# format of SELECT response class FcpTemplate), so that higher-level code only has to deal with one respresentation. See also
3GPP TS 51.011, section 9.2.1
"""
resp_bin = h2b(resp_hex) resp_bin = h2b(resp_hex)
struct_of_file_map = { struct_of_file_map = {
0: 'transparent', 0: 'transparent',
@@ -1241,13 +1305,24 @@ class CardProfileSIM(CardProfile):
record_len = resp_bin[14] record_len = resp_bin[14]
ret['file_descriptor']['record_len'] = record_len ret['file_descriptor']['record_len'] = record_len
ret['file_descriptor']['num_of_rec'] = ret['file_size'] // record_len ret['file_descriptor']['num_of_rec'] = ret['file_size'] // record_len
ret['access_conditions'] = b2h(resp_bin[8:10]) ret['access_conditions'] = b2h(resp_bin[8:11])
if resp_bin[11] & 0x01 == 0:
# Life cycle status integer, see also ETSI TS 102 221, table 11.7b
lcsi = resp_bin[11]
if lcsi == 0x00:
ret['life_cycle_status_int'] = 'no_information'
elif lcsi == 0x01:
ret['life_cycle_status_int'] = 'creation'
elif lcsi == 0x03:
ret['life_cycle_status_int'] = 'initialization'
elif lcsi & 0xFD == 0x05:
ret['life_cycle_status_int'] = 'operational_activated' ret['life_cycle_status_int'] = 'operational_activated'
elif resp_bin[11] & 0x04: elif lcsi & 0xFD == 0x04:
ret['life_cycle_status_int'] = 'operational_deactivated' ret['life_cycle_status_int'] = 'operational_deactivated'
elif lcsi & 0xFC == 0x0C:
ret['life_cycle_status_int'] = 'termination'
else: else:
ret['life_cycle_status_int'] = 'terminated' ret['life_cycle_status_int'] = lcsi
return ret return ret
@classmethod @classmethod
+2 -2
View File
@@ -139,7 +139,6 @@ def enc_plmn(mcc: Hexstr, mnc: Hexstr) -> Hexstr:
def dec_plmn(threehexbytes: Hexstr) -> dict: def dec_plmn(threehexbytes: Hexstr) -> dict:
res = {'mcc': "0", 'mnc': "0"} res = {'mcc': "0", 'mnc': "0"}
dec_mcc_from_plmn_str(threehexbytes)
res['mcc'] = dec_mcc_from_plmn_str(threehexbytes) res['mcc'] = dec_mcc_from_plmn_str(threehexbytes)
res['mnc'] = dec_mnc_from_plmn_str(threehexbytes) res['mnc'] = dec_mnc_from_plmn_str(threehexbytes)
return res return res
@@ -911,7 +910,8 @@ class DataObjectCollection:
def encode(self, decoded) -> bytes: def encode(self, decoded) -> bytes:
res = bytearray() res = bytearray()
for i in decoded: 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()) res.append(obj.to_tlv())
return res return res
+4
View File
@@ -4,3 +4,7 @@ build-backend = "setuptools.build_meta"
[tool.pylint.main] [tool.pylint.main]
ignored-classes = ["twisted.internet.reactor"] ignored-classes = ["twisted.internet.reactor"]
[tool.pylint.TYPECHECK]
# SdKey subclasses are generated dynamically via SdKey.generate_sd_key_classes()
generated-members = ["SdKey[A-Za-z0-9]+"]
+2 -2
View File
@@ -1,12 +1,12 @@
pyscard pyscard
pyserial pyserial
pytlv pytlv
cmd2>=2.6.2,<3.0 cmd2>=2.6.2,<4.0
jsonpath-ng jsonpath-ng
construct>=2.10.70 construct>=2.10.70
bidict bidict
pyosmocom>=0.0.12 pyosmocom>=0.0.12
pyyaml>=5.1 pyyaml>=5.4
termcolor termcolor
colorlog colorlog
pycryptodomex pycryptodomex
+2 -2
View File
@@ -21,12 +21,12 @@ setup(
"pyscard", "pyscard",
"pyserial", "pyserial",
"pytlv", "pytlv",
"cmd2 >= 1.5.0, < 3.0", "cmd2 >= 2.6.2, < 4.0",
"jsonpath-ng", "jsonpath-ng",
"construct >= 2.10.70", "construct >= 2.10.70",
"bidict", "bidict",
"pyosmocom >= 0.0.12", "pyosmocom >= 0.0.12",
"pyyaml >= 5.1", "pyyaml >= 5.4",
"termcolor", "termcolor",
"colorlog", "colorlog",
"pycryptodomex", "pycryptodomex",
+2 -2
View File
@@ -1,11 +1,11 @@
Using PC/SC reader interface INFO: Using PC/SC reader interface
Reading ... Reading ...
Autodetected card type: Fairwaves-SIM Autodetected card type: Fairwaves-SIM
ICCID: 8988219000000117833 ICCID: 8988219000000117833
IMSI: 001010000000111 IMSI: 001010000000111
GID1: ffffffffffffffff GID1: ffffffffffffffff
GID2: ffffffffffffffff GID2: ffffffffffffffff
SMSP: e1ffffffffffffffffffffffff0581005155f5ffffffffffff000000ffffffffffffffffffffffffffff SMSP: ffffffffffffffffffffffffffffe1ffffffffffffffffffffffff0581005155f5ffffffffffff000000
SMSC: 0015555 SMSC: 0015555
SPN: Fairwaves SPN: Fairwaves
Show in HPLMN: False Show in HPLMN: False
+2 -2
View File
@@ -1,11 +1,11 @@
Using PC/SC reader interface INFO: Using PC/SC reader interface
Reading ... Reading ...
Autodetected card type: Wavemobile-SIM Autodetected card type: Wavemobile-SIM
ICCID: 89445310150011013678 ICCID: 89445310150011013678
IMSI: 001010000000102 IMSI: 001010000000102
GID1: Can't read file -- SW match failed! Expected 9000 and got 6a82. 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. GID2: Can't read file -- SW match failed! Expected 9000 and got 6a82.
SMSP: e1ffffffffffffffffffffffff0581005155f5ffffffffffff000000ffffffffffffffffffffffffffff SMSP: ffffffffffffffffffffffffffffe1ffffffffffffffffffffffff0581005155f5ffffffffffff000000
SMSC: 0015555 SMSC: 0015555
SPN: wavemobile SPN: wavemobile
Show in HPLMN: False Show in HPLMN: False
+1 -1
View File
@@ -1,4 +1,4 @@
Using PC/SC reader interface INFO: Using PC/SC reader interface
Reading ... Reading ...
Autodetected card type: fakemagicsim Autodetected card type: fakemagicsim
ICCID: 1122334455667788990 ICCID: 1122334455667788990
+1 -1
View File
@@ -1,4 +1,4 @@
Using PC/SC reader interface INFO: Using PC/SC reader interface
Reading ... Reading ...
Autodetected card type: sysmoISIM-SJA2 Autodetected card type: sysmoISIM-SJA2
ICCID: 8988211000000467343 ICCID: 8988211000000467343
+1 -1
View File
@@ -1,4 +1,4 @@
Using PC/SC reader interface INFO: Using PC/SC reader interface
Reading ... Reading ...
Autodetected card type: sysmoISIM-SJA5 Autodetected card type: sysmoISIM-SJA5
ICCID: 8949440000001155314 ICCID: 8949440000001155314
+1 -1
View File
@@ -1,4 +1,4 @@
Using PC/SC reader interface INFO: Using PC/SC reader interface
Reading ... Reading ...
Autodetected card type: sysmoUSIM-SJS1 Autodetected card type: sysmoUSIM-SJS1
ICCID: 8988211320300000028 ICCID: 8988211320300000028
+1 -1
View File
@@ -1,4 +1,4 @@
Using PC/SC reader interface INFO: Using PC/SC reader interface
Reading ... Reading ...
Autodetected card type: sysmosim-gr1 Autodetected card type: sysmosim-gr1
ICCID: 2222334455667788990 ICCID: 2222334455667788990
@@ -7,10 +7,24 @@ set apdu_strict true
# No command data field, No response data field present # No command data field, No response data field present
apdu 00700001 --expect-sw 9000 --expect-response-regex '^$' apdu 00700001 --expect-sw 9000 --expect-response-regex '^$'
# Case #1: (verify pin)
# This command returns the number of remaining authentication attempts in the
# form of a status that has the form 63cX, where X is the number of remaining
# attempts. Such a status word can be easily confused with the response to a
# case #4 APDU. This test checks if the transport layer correctly distinguishes
# the between APDU case #1 and APDU case #4.
apdu 0020000A --expect-sw 63c? --expect-response-regex '^$'
# Case #2: (status) # Case #2: (status)
# No command data field, Response data field present # No command data field, Response data field present
apdu 80F2000000 --expect-sw 9000 --expect-response-regex '^[a-fA-F0-9]+$' apdu 80F2000000 --expect-sw 9000 --expect-response-regex '^[a-fA-F0-9]+$'
# Case #2: (verify pin)
# (see also above). This test checks if the transport layer is also able to
# distinguish correctly between APDU case #2 (with zero length response) and
# APDU case #4.
apdu 0020000A00 --expect-sw 63c? --expect-response-regex '^$'
# Case #3: (terminal capability) # Case #3: (terminal capability)
# Command data field present, No response data field # Command data field present, No response data field
apdu 80AA000005a903830180 --expect-sw 9000 --expect-response-regex '^$' apdu 80AA000005a903830180 --expect-sw 9000 --expect-response-regex '^$'
@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# Utility to verify the functionality of pySim-trace.py # Utility to verify the functionality of pySim-smpp2sim.py
# #
# (C) 2026 by sysmocom - s.f.m.c. GmbH # (C) 2026 by sysmocom - s.f.m.c. GmbH
# All Rights Reserved # All Rights Reserved
+1
View File
@@ -0,0 +1 @@
../../smdpp-data
+417
View File
@@ -0,0 +1,417 @@
#!/usr/bin/env python3
# (C) 2026 by sysmocom - s.f.m.c. GmbH <info@sysmocom.de>
#
# Author: Eric Wild
#
# 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 socket
import threading
import time
import unittest
from osmocom.utils import b2h, h2b
from pySim.sms import SMS_SUBMIT, AddressField
from pySim.cat import (ProactiveCommand, CommandDetails, DeviceIdentities,
BearerDescription, BufferSize, UiccTransportLevel,
OtherAddress, ChannelData, ChannelDataLength, ChannelStatus,
Result, LocationInformation)
from pySim.bip import Proact, ProactChannels, terminal_profile
class _EchoServer:
"""behold, my tiny threaded TCP echo server listening on 127.0.0.1:<port>"""
def __init__(self):
self._srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self._srv.bind(('127.0.0.1', 0))
self._srv.listen(1)
self.port = self._srv.getsockname()[1]
self.accepted = threading.Event()
self._conns = []
self._stop = False
threading.Thread(target=self._run, daemon=True).start()
def _run(self):
try:
conn, _ = self._srv.accept()
except OSError:
return
self._conns.append(conn)
self.accepted.set()
while not self._stop:
try:
data = conn.recv(4096)
except OSError:
break
if not data:
break
conn.sendall(data)
def close(self):
self._stop = True
for s in [self._srv] + self._conns:
try:
s.close()
except OSError:
pass
def _pcmd(children_tlvs):
"""Assemble D0 proactive-command TLV from child IE bytes,
decode it like transport does after a FETCH"""
body = b''.join(children_tlvs)
pdu = h2b('D0') + bytes([len(body)]) + body
return ProactiveCommand().from_tlv(pdu)
def _open_channel(port, ip='127.0.0.1', cmd_nr=1):
a, b, c, d = (int(x) for x in ip.split('.'))
return _pcmd([
CommandDetails(decoded={'command_number': cmd_nr, 'type_of_command': 'open_channel',
'command_qualifier': 3}).to_tlv(),
DeviceIdentities(decoded={'source_dev_id': 'uicc', 'dest_dev_id': 'terminal'}).to_tlv(),
BearerDescription(decoded={'bearer_type': 'default', 'bearer_parameters': b''}).to_tlv(),
BufferSize(decoded=1024).to_tlv(),
UiccTransportLevel(decoded={'protocol_type': 'tcp_uicc_client_remote',
'port_number': port}).to_tlv(),
OtherAddress(decoded={'type_of_address': 'ipv4',
'address': bytes([a, b, c, d])}).to_tlv(),
])
def _open_channel_raw(extra_ies, cmd_nr=1):
"""OPEN CHANNEL with only the head data"""
return _pcmd([
CommandDetails(decoded={'command_number': cmd_nr, 'type_of_command': 'open_channel',
'command_qualifier': 3}).to_tlv(),
DeviceIdentities(decoded={'source_dev_id': 'uicc', 'dest_dev_id': 'terminal'}).to_tlv(),
BearerDescription(decoded={'bearer_type': 'default', 'bearer_parameters': b''}).to_tlv(),
BufferSize(decoded=1024).to_tlv(),
] + extra_ies)
def _send_data(payload, chan='channel_1', cmd_nr=1):
return _pcmd([
CommandDetails(decoded={'command_number': cmd_nr, 'type_of_command': 'send_data',
'command_qualifier': 1}).to_tlv(),
DeviceIdentities(decoded={'source_dev_id': 'uicc', 'dest_dev_id': chan}).to_tlv(),
ChannelData(decoded=b2h(payload)).to_tlv(),
])
def _receive_data(length, chan='channel_1', cmd_nr=1):
return _pcmd([
CommandDetails(decoded={'command_number': cmd_nr, 'type_of_command': 'receive_data',
'command_qualifier': 0}).to_tlv(),
DeviceIdentities(decoded={'source_dev_id': 'uicc', 'dest_dev_id': chan}).to_tlv(),
ChannelDataLength(decoded=length).to_tlv(),
])
def _close_channel(chan='channel_1', cmd_nr=1):
return _pcmd([
CommandDetails(decoded={'command_number': cmd_nr, 'type_of_command': 'close_channel',
'command_qualifier': 0}).to_tlv(),
DeviceIdentities(decoded={'source_dev_id': 'uicc', 'dest_dev_id': chan}).to_tlv(),
])
def _first(til, cls):
return next((x for x in til if isinstance(x, cls)), None)
class BipRelayRoundTripTest(unittest.TestCase):
"""Drive the fixed Proact handlers (blocking sockets) with synthetic
proactive commands against a local echo server and assert a byte round-trip
plus the channel bookkeeping / error handling."""
def setUp(self):
self.echo = _EchoServer()
self.addCleanup(self.echo.close)
self.events = []
self.proact = Proact(data_available_sink=self.events.append)
self.addCleanup(self._close_all_channels)
def _close_all_channels(self):
for chan in list(self.proact.channels.channels.values()):
try:
chan.close()
except Exception:
pass
def _open(self, cmd_nr=1):
til = self.proact.handle_OpenChannel(_open_channel(self.echo.port, cmd_nr=cmd_nr))
# every TLV in the response must serialise (the transport does exactly
# this to post the TERMINAL RESPONSE)
b''.join(x.to_tlv() for x in til)
return til
def test_open_send_receive_roundtrip(self):
# OPEN CHANNEL -> socket connected, channel 1 opened, link established
til = self._open()
self.assertTrue(self.echo.accepted.wait(timeout=2.0))
self.assertIn(1, self.proact.channels.channels)
cd = _first(til, CommandDetails)
self.assertEqual(cd.decoded['type_of_command'], 'open_channel')
# TS 102 223 6.8.2 TERMINAL RESPONSE device id: terminal -> UICC
self.assertEqual(b2h(_first(til, DeviceIdentities).to_tlv()), '82028281')
# channel status: channel 1, link established
self.assertEqual(_first(til, ChannelStatus).decoded, '8100')
self.assertEqual(_first(til, Result).decoded['general_result'], 'performed_successfully')
# SEND DATA -> bytes written to the socket, echo server sends them back
payload = b'Hello SCP81 relay - opaque TLS record bytes'
til = self.proact.handle_SendData(_send_data(payload))
b''.join(x.to_tlv() for x in til)
# channel data length in the response = free Tx space, FF = ">255"
self.assertEqual(_first(til, ChannelDataLength).decoded, 255)
self.assertEqual(_first(til, Result).decoded['general_result'], 'performed_successfully')
# RECEIVE DATA -> drain the bytes back to the "card". real card
# uses data-available event, we poll the buffer
# and may need several RECEIVE DATA commands, as the spec allows.
got = bytearray()
deadline = time.monotonic() + 3.0
while len(got) < len(payload) and time.monotonic() < deadline:
chan = self.proact.channels.channels[1]
chan.wait_rx(1.0)
til = self.proact.handle_ReceiveData(_receive_data(len(payload) - len(got)))
b''.join(x.to_tlv() for x in til)
self.assertEqual(b2h(_first(til, DeviceIdentities).to_tlv()), '82028281')
got += h2b(_first(til, ChannelData).decoded)
self.assertEqual(bytes(got), payload, "byte round-trip through the BIP relay")
# CLOSE CHANNEL -> socket closed, bookkeeping cleared
til = self.proact.handle_CloseChannel(_close_channel())
b''.join(x.to_tlv() for x in til)
self.assertEqual(_first(til, Result).decoded['general_result'], 'performed_successfully')
self.assertNotIn(1, self.proact.channels.channels)
def test_data_available_event_envelope(self):
# The empty->non-empty Rx transition raises ENVELOPE EVENT DOWNLOAD
self._open()
self.assertTrue(self.echo.accepted.wait(timeout=2.0))
payload = b'PONG'
self.proact.handle_SendData(_send_data(payload))
chan = self.proact.channels.channels[1]
self.assertGreater(chan.wait_rx(2.0), 0)
# give the reader thread a beat to invoke the sink
deadline = time.monotonic() + 2.0
while not self.events and time.monotonic() < deadline:
time.sleep(0.01)
self.assertEqual(len(self.events), 1, "one data-available event on the empty->non-empty edge")
env = h2b(self.events[0])
# d6 0e | 99 01 09 (event: data available) | 82 02 82 81 terminal->UICC
# | b8 02 81 00 (channel 1 established) | b7 01 XX bytes available
self.assertEqual(b2h(env[:15]), 'd60e99010982028281b8028100b701')
self.assertGreaterEqual(env[15], 1)
self.assertLessEqual(env[15], len(payload))
def test_channel_number_from_device_identities(self):
# Two channels, not the old hardcoded 1
e2 = _EchoServer()
self.addCleanup(e2.close)
self.proact.handle_OpenChannel(_open_channel(self.echo.port))
# open a second channel with a second echo server
til2 = self.proact.handle_OpenChannel(_open_channel(e2.port))
self.assertEqual(sorted(self.proact.channels.channels), [1, 2])
self.assertEqual(_first(til2, ChannelStatus).decoded, '8200') # channel 2, established
# SEND DATA addressed to channel_2 must reach the second socket
self.assertTrue(e2.accepted.wait(timeout=2.0))
self.proact.handle_SendData(_send_data(b'two', chan='channel_2'))
chan2 = self.proact.channels.channels[2]
self.assertGreater(chan2.wait_rx(2.0), 0)
til = self.proact.handle_ReceiveData(_receive_data(3, chan='channel_2'))
self.assertEqual(h2b(_first(til, ChannelData).decoded), b'two')
# ..and nothing on chan 1
self.assertEqual(self.proact.channels.channels[1].available_rx(), 0)
def test_commands_on_closed_channel_report_bip_error(self):
# SEND/RECEIVE/CLOSE on a channel that was never opened must be rejected
# with a BIP error
for til in (self.proact.handle_SendData(_send_data(b'x', chan='channel_4')),
self.proact.handle_ReceiveData(_receive_data(1, chan='channel_4')),
self.proact.handle_CloseChannel(_close_channel(chan='channel_4'))):
b''.join(x.to_tlv() for x in til)
res = _first(til, Result).decoded
self.assertEqual(res['general_result'], 'bearer_independent_protocol_error')
self.assertEqual(res['additional_information'], 'channel_id_not_valid')
def test_receive_more_than_available_is_missing_info(self):
# terminal must NOT wait if fewer than the requested bytes are buffered,
# eturns what it has with "performed with missing information".
self._open()
self.assertTrue(self.echo.accepted.wait(timeout=2.0))
til = self.proact.handle_ReceiveData(_receive_data(10))
b''.join(x.to_tlv() for x in til)
self.assertEqual(_first(til, Result).decoded['general_result'],
'performed_with_missing_information')
self.assertEqual(h2b(_first(til, ChannelData).decoded), b'')
self.assertEqual(_first(til, ChannelDataLength).decoded, 0)
class OpenChannelRefusalTest(unittest.TestCase):
"""Refusal is a TERMINAL RESPONSE, not an exception, raising takes the whole
proactive session down and leaves the card wondering why"""
ADDR = OtherAddress(decoded={'type_of_address': 'ipv4', 'address': bytes([127, 0, 0, 1])})
TCP = UiccTransportLevel(decoded={'protocol_type': 'tcp_uicc_client_remote', 'port_number': 1234})
def setUp(self):
self.proact = Proact()
self.addCleanup(self._close_all_channels)
def _close_all_channels(self):
for chan in list(self.proact.channels.channels.values()):
try:
chan.close()
except Exception:
pass
def _assert_refused(self, til, additional_information, chan_nr=0):
b''.join(x.to_tlv() for x in til) # must serialise, the transport posts it
res = _first(til, Result).decoded
self.assertEqual(res['general_result'], 'bearer_independent_protocol_error')
self.assertEqual(res['additional_information'], additional_information)
self.assertEqual(_first(til, ChannelStatus).decoded, '%02x00' % chan_nr) # 8.56
self.assertIsNotNone(_first(til, BearerDescription)) # 6.8.20
self.assertIsNotNone(_first(til, BufferSize)) # 6.8.21
self.assertEqual(b2h(_first(til, DeviceIdentities).to_tlv()), '82028281') # 6.8.2
def test_transport_level(self):
cases = [[self.ADDR.to_tlv()]] # absent, 6.6.27.x Optional
for proto in ('udp_uicc_client_remote', 'tcp_uicc_server', 'udp_uicc_client_local',
'tcp_uicc_client_local', 'direct_channel'): # not TCP client remote
tl = UiccTransportLevel(decoded={'protocol_type': proto, 'port_number': 1234})
cases.append([tl.to_tlv(), self.ADDR.to_tlv()])
for extra in cases:
with self.subTest(extra=b2h(extra[0])):
self._assert_refused(self.proact.handle_OpenChannel(_open_channel_raw(extra)),
'requested_uicc_if_transp_level_not_available')
def test_destination_address(self):
v6 = OtherAddress(decoded={'type_of_address': 'ipv6', 'address': bytes(16)})
for extra in ([self.TCP.to_tlv()], # absent
[self.TCP.to_tlv(), v6.to_tlv()]): # not IPv4
with self.subTest(extra=len(extra)):
self._assert_refused(self.proact.handle_OpenChannel(_open_channel_raw(extra)),
'no_specific_cause')
def test_no_channel_left(self):
for _ in range(7): # 6.4.27.2, 6.4.27.3
self.proact.channels.channel_create()
cmd = _open_channel_raw([self.TCP.to_tlv(), self.ADDR.to_tlv()])
self._assert_refused(self.proact.handle_OpenChannel(cmd), 'no_channel_availabile')
def test_connect_failure(self):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # port nothing listens on
s.bind(('127.0.0.1', 0))
dead_port = s.getsockname()[1]
s.close()
til = self.proact.handle_OpenChannel(_open_channel(dead_port))
self._assert_refused(til, 'channel_closed', chan_nr=1) # 6.4.30
self.assertEqual(self.proact.channels.channels, {}) # channel given back
class ProvideLocalInformationTest(unittest.TestCase):
"""TS 102 223 6.8.7: only 00 gets a data object; the rest keeps the empty result."""
def _cmd(self, qualifier):
return _pcmd([
CommandDetails(decoded={'command_number': 1, 'type_of_command': 'provide_local_info',
'command_qualifier': qualifier}).to_tlv(),
DeviceIdentities(decoded={'source_dev_id': 'uicc', 'dest_dev_id': 'terminal'}).to_tlv()])
def test_location(self):
til = Proact().handle_ProvideLocalInformation(self._cmd(0x00))
b''.join(x.to_tlv() for x in til)
self.assertEqual(_first(til, Result).decoded['general_result'], 'performed_successfully')
self.assertEqual(b2h(_first(til, LocationInformation).to_tlv()), '930762f21000010001')
self.assertEqual(b2h(_first(til, DeviceIdentities).to_tlv()), '82028281') # 6.8.2
def test_other_qualifiers_get_no_data_object(self):
for qualifier in (0x01, 0x03, 0x04, 0x1a):
with self.subTest(command_qualifier=qualifier):
til = Proact().handle_ProvideLocalInformation(self._cmd(qualifier))
b''.join(x.to_tlv() for x in til)
self.assertIsNone(_first(til, LocationInformation))
def test_location_is_configurable(self):
til = Proact(location=h2b('26f8100539')).handle_ProvideLocalInformation(self._cmd(0x00))
self.assertEqual(b2h(_first(til, LocationInformation).to_tlv()), '930526f8100539')
class TerminalProfileTest(unittest.TestCase):
"""TS 102 223 5.2, one bit per CAT facility"""
def setUp(self):
self.profile = terminal_profile()
def byte(self, n):
return self.profile[n - 1] # 1-based, as 5.2 numbers them
def test_announced(self):
self.assertEqual(len(self.profile), 32)
self.assertEqual(self.byte(1), 0x13) # profile download, SMS-PP download b2+b5
self.assertEqual(self.byte(4), 0x02) # SEND SHORT MESSAGE
self.assertEqual(self.byte(5) & 0x01, 0x01) # SET UP EVENT LIST
self.assertEqual(self.byte(6), 0x0c) # events: data available, channel status
self.assertEqual(self.byte(12), 0x1f) # OPEN/CLOSE CHANNEL, RECEIVE/SEND DATA, STATUS
self.assertEqual(self.byte(13) >> 5, ProactChannels.MAX_CHANNELS)
self.assertEqual(self.byte(14), 0x60) # class ND, class NK
self.assertEqual(self.byte(17), 0x01) # TCP, UICC client mode, remote
def test_not_announced(self):
self.assertEqual(self.byte(3) & 0x60, 0) # POLL INTERVAL, POLLING OFF
self.assertEqual(self.byte(4) & 0xc0, 0) # PROVIDE LOCAL INFORMATION, NMR
self.assertEqual(self.byte(12) & 0xe0, 0) # SERVICE SEARCH/INFORMATION, DECLARE SERVICE
self.assertEqual(self.byte(14) & 0x1f, 0) # no characters down the display
for n in (7, 9, 10, 11, 15, 16, 18): # class "a", class "d", display, ESN/IMEISV
self.assertEqual(self.byte(n), 0)
def test_channel_count(self):
self.assertEqual(terminal_profile(3)[12] >> 5, 3)
with self.assertRaises(ValueError): # 8.56: 1 to 7
terminal_profile(8)
if __name__ == "__main__":
unittest.main()
class BipSinkTest(unittest.TestCase):
"""Both sinks are optional, a driver with no SMS path at all must not crash and burn
with a card that sends one, and one that has one must get the PDU."""
def _submit(self):
return SMS_SUBMIT(tp_da=AddressField('12345', 'unknown', 'unknown'),
tp_ud=b'\x01\x02', tp_udl=2, tp_dcs=0xf6)
def test_sinks_default_to_none(self):
p = Proact()
self.assertIsNone(p.sms_sink)
def test_mo_sms_goes_to_the_sink(self):
seen = []
Proact(sms_sink=seen.append).send_sms_via_smpp(self._submit())
self.assertEqual(len(seen), 1)
def test_no_sms_sink_drops_instead_of_raising(self):
with self.assertLogs('pySim.bip', level='INFO'):
Proact().send_sms_via_smpp(self._submit())
+2 -1
View File
@@ -20,7 +20,8 @@ class TestCardKeyProviderCsv(unittest.TestCase):
"KIK3" : "00010204040506070809488B0C0D0E0F"} "KIK3" : "00010204040506070809488B0C0D0E0F"}
csv_file_path = os.path.dirname(os.path.abspath(__file__)) + "/test_card_key_provider.csv" csv_file_path = os.path.dirname(os.path.abspath(__file__)) + "/test_card_key_provider.csv"
card_key_provider_register(CardKeyProviderCsv(csv_file_path, column_keys)) card_key_field_cryptor = CardKeyFieldCryptor(column_keys)
card_key_provider_register(CardKeyProviderCsv(csv_file_path, card_key_field_cryptor))
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def test_card_key_provider_get(self): def test_card_key_provider_get(self):
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Tests for the CAT (Card Application Toolkit) COMPREHENSION-TLV data objects"""
# (C) 2026 by sysmocom - s.f.m.c. GmbH
# All Rights Reserved
#
# Author: Eric Wild <ewild@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/>.
# IEs not properly coverd by test_tlvs.py
import unittest
from osmocom.utils import b2h, h2b
from pySim.cat import IMEI, IMEISV, AccessTechnology, SupportedRadioAccessTechnologies
class IMEI_Test(unittest.TestCase):
"""TS 102 223 8.20: the IMEI IE is 8 bytes, coded as valie part of Mobile Identity IE from 124 008"""
IMEI_15 = '123456789012345'
ENCODED = '94081a32547698103254'
def test_encode_is_eight_bytes(self):
"""15 digits in 8 byte: 16 nibbles, one is type/parity framing."""
tlv = IMEI(decoded=self.IMEI_15).to_tlv()
self.assertEqual(b2h(tlv), self.ENCODED)
self.assertEqual(tlv[1], 0x08) # spec len 8
self.assertEqual(len(tlv) - 2, 8)
def test_first_octet_framing(self):
"""TS 24.008 table 10.5.4"""
octet1 = IMEI(decoded=self.IMEI_15).to_tlv()[2]
self.assertEqual(octet1 & 0x07, 2) # IMEI
self.assertEqual((octet1 >> 3) & 0x01, 1) # odd
self.assertEqual(octet1 >> 4, 1) # digit 1
def test_decodes_to_the_raw_imei(self):
"""strip framing nibble"""
ie = IMEI()
ie.from_tlv(h2b(self.ENCODED))
self.assertEqual(ie.decoded, self.IMEI_15)
def test_even_digit_count_uses_the_end_mark(self):
""""end marker, IMEISV case"""
ie = IMEI(decoded='1234567890123456')
tlv = ie.to_tlv()
self.assertEqual(tlv[2] >> 3 & 0x01, 0) # even
self.assertEqual(tlv[-1] >> 4, 0x0f) # end mark
back = IMEI()
back.from_tlv(tlv)
self.assertEqual(back.decoded, '1234567890123456')
class IMEISV_Test(unittest.TestCase):
"""TS 102 223 8.74, no fixed len, end marker"""
IMEISV_16 = '1234567890123456'
ENCODED = 'e2091332547698103254f6'
def test_encode(self):
self.assertEqual(b2h(IMEISV(decoded=self.IMEISV_16).to_tlv()), self.ENCODED)
def test_type_of_identity_and_end_mark(self):
value = IMEISV(decoded=self.IMEISV_16).to_tlv()[2:]
self.assertEqual(value[0] & 0x07, 3) # IMEISV
self.assertEqual((value[0] >> 3) & 0x01, 0) # even
self.assertEqual(value[-1] >> 4, 0x0f) # end mark
self.assertEqual(len(value), 9)
def test_decode(self):
ie = IMEISV()
ie.from_tlv(h2b(self.ENCODED))
self.assertEqual(ie.decoded, self.IMEISV_16)
class SupportedRadioAccessTechnologies_Test(unittest.TestCase):
"""TS 102 223 8.105"""
def test_encode_technology_enabled(self):
"""The flag used to have a bitmask of 0 so enabled -> 00 (that is disabled..)"""
ie = SupportedRadioAccessTechnologies(
decoded=[{'technology': 'eutran', 'state': {'enabled': True}}])
self.assertEqual(b2h(ie.to_tlv()), 'b4020801')
def test_encode_technology_disabled(self):
ie = SupportedRadioAccessTechnologies(
decoded=[{'technology': 'eutran', 'state': {'enabled': False}}])
self.assertEqual(b2h(ie.to_tlv()), 'b4020800')
def test_decode_technology(self):
"""old 0 bitmask = all enabled, no way to disable"""
for encoded, enabled in [('b4020800', False), ('b4020801', True)]:
with self.subTest(encoded=encoded):
ie = SupportedRadioAccessTechnologies()
ie.from_tlv(h2b(encoded))
self.assertEqual(ie.decoded[0]['technology'], 'eutran')
self.assertEqual(ie.decoded[0]['state']['enabled'], enabled)
def test_decode_technology_multiple(self):
ie = SupportedRadioAccessTechnologies()
ie.from_tlv(h2b('b40408010000'))
self.assertEqual([(e['technology'], e['state']['enabled']) for e in ie.decoded],
[('eutran', True), ('gsm', False)])
if __name__ == "__main__":
unittest.main()
+640
View File
@@ -0,0 +1,640 @@
#!/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 enum
import io
import sys
import unittest
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',
)
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.SdKeyScp81Kvn40Tlspsk,
#p13n.SdKeyScp81Kvn41AesDek,
p13n.SdKeyScp81Kvn41Tlspsk,
#p13n.SdKeyScp81Kvn42AesDek,
#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]}"')
class TestValidateVal(unittest.TestCase):
"""validate_val() tests for various ConfigurableParameter subclasses."""
def _ok(self, cls, val, expected=None):
result = cls.validate_val(val)
if expected is not None:
self.assertEqual(result, expected)
return result
def _err(self, cls, val):
with self.assertRaises(ValueError):
cls.validate_val(val)
# --- Iccid ---
def test_iccid_18digits_adds_luhn(self):
result = self._ok(p13n.Iccid, '998877665544332211')
self.assertIsInstance(result, str)
self.assertEqual(len(result), 19)
self.assertTrue(result.isdecimal())
def test_iccid_19digits_passthrough(self):
result = self._ok(p13n.Iccid, '9988776655443322110')
self.assertIsInstance(result, str)
self.assertEqual(len(result), 19)
def test_iccid_too_short(self):
self._err(p13n.Iccid, '12345678901234567') # 17 digits
def test_iccid_too_long(self):
self._err(p13n.Iccid, '1' * 21)
def test_iccid_non_digits(self):
self._err(p13n.Iccid, '99887766554433221X')
# --- Imsi ---
def test_imsi_valid_short(self):
self._ok(p13n.Imsi, '001010', '001010')
def test_imsi_valid_long(self):
self._ok(p13n.Imsi, '001010123456789', '001010123456789')
def test_imsi_too_short(self):
self._err(p13n.Imsi, '12345') # 5 digits, min is 6
def test_imsi_too_long(self):
self._err(p13n.Imsi, '1' * 16)
def test_imsi_non_digits(self):
self._err(p13n.Imsi, '00101A123456789')
# --- Pin1 ---
def test_pin1_4digits(self):
# DecimalHexParam encodes each digit as its ASCII byte, then rpad to 8 bytes with 0xff
self._ok(p13n.Pin1, '1234', b'1234\xff\xff\xff\xff')
def test_pin1_8digits(self):
self._ok(p13n.Pin1, '12345678', b'12345678')
def test_pin1_too_short(self):
self._err(p13n.Pin1, '123')
def test_pin1_too_long(self):
self._err(p13n.Pin1, '123456789')
def test_pin1_non_digits(self):
self._err(p13n.Pin1, '123A')
# --- Puk1 ---
def test_puk1_8digits(self):
self._ok(p13n.Puk1, '12345678', b'12345678')
def test_puk1_wrong_length(self):
self._err(p13n.Puk1, '1234567') # 7 digits
self._err(p13n.Puk1, '123456789') # 9 digits
def test_puk1_non_digits(self):
self._err(p13n.Puk1, '1234567X')
# --- K (BinaryParam) ---
def test_k_valid_hex_str(self):
self._ok(p13n.K, '000102030405060708090a0b0c0d0e0f',
b'\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f')
def test_k_valid_bytes(self):
raw = bytes(range(16))
self._ok(p13n.K, raw, raw)
def test_k_wrong_length(self):
self._err(p13n.K, '00' * 15) # 15 bytes, allow_len requires 16 or 32
def test_k_non_hex(self):
self._err(p13n.K, 'gg' * 16)
def test_k_odd_hex_digits(self):
self._err(p13n.K, '0' * 31) # odd number of hex digits
class TestEnumParam(unittest.TestCase):
"""Tests for the EnumParam machinery, using AlgorithmID as the concrete subclass."""
# --- validate_val ---
def test_validate_by_name_exact(self):
self.assertEqual(p13n.AlgorithmID.validate_val('Milenage'), 1)
self.assertEqual(p13n.AlgorithmID.validate_val('TUAK'), 2)
self.assertEqual(p13n.AlgorithmID.validate_val('usim_test'), 3)
def test_validate_by_int(self):
self.assertEqual(p13n.AlgorithmID.validate_val(1), 1)
self.assertEqual(p13n.AlgorithmID.validate_val(2), 2)
self.assertEqual(p13n.AlgorithmID.validate_val(3), 3)
def test_validate_fuzzy_case(self):
self.assertEqual(p13n.AlgorithmID.validate_val('milenage'), 1)
self.assertEqual(p13n.AlgorithmID.validate_val('MILENAGE'), 1)
self.assertEqual(p13n.AlgorithmID.validate_val('tuak'), 2)
def test_validate_fuzzy_hyphen_underscore(self):
# 'usim-test' has a hyphen; enum member is 'usim_test' — must fuzzy-match
self.assertEqual(p13n.AlgorithmID.validate_val('usim-test'), 3)
def test_validate_invalid_name(self):
with self.assertRaises(ValueError):
p13n.AlgorithmID.validate_val('unknown')
def test_validate_invalid_int(self):
with self.assertRaises(ValueError):
p13n.AlgorithmID.validate_val(99)
def test_validate_returns_int(self):
result = p13n.AlgorithmID.validate_val('Milenage')
self.assertIsInstance(result, int)
self.assertNotIsInstance(result, enum.Enum)
# --- map_name_to_val ---
def test_map_name_exact(self):
self.assertEqual(p13n.AlgorithmID.map_name_to_val('Milenage'), 1)
def test_map_name_fuzzy(self):
self.assertEqual(p13n.AlgorithmID.map_name_to_val('milenage'), 1)
self.assertEqual(p13n.AlgorithmID.map_name_to_val('usim-test'), 3)
def test_map_name_strict_raises(self):
with self.assertRaises(ValueError):
p13n.AlgorithmID.map_name_to_val('unknown', strict=True)
def test_map_name_nonstrict_returns_none(self):
self.assertIsNone(p13n.AlgorithmID.map_name_to_val('unknown', strict=False))
# --- map_val_to_name ---
def test_map_val_known(self):
self.assertEqual(p13n.AlgorithmID.map_val_to_name(1), 'Milenage')
self.assertEqual(p13n.AlgorithmID.map_val_to_name(2), 'TUAK')
self.assertEqual(p13n.AlgorithmID.map_val_to_name(3), 'usim_test')
def test_map_val_unknown_nonstrict(self):
self.assertIsNone(p13n.AlgorithmID.map_val_to_name(99))
def test_map_val_unknown_strict(self):
with self.assertRaises(ValueError):
p13n.AlgorithmID.map_val_to_name(99, strict=True)
# --- name_normalize ---
def test_name_normalize(self):
self.assertEqual(p13n.AlgorithmID.name_normalize('Milenage'), 'Milenage')
self.assertEqual(p13n.AlgorithmID.name_normalize('milenage'), 'Milenage')
self.assertEqual(p13n.AlgorithmID.name_normalize('usim-test'), 'usim_test')
# --- clean_name_str ---
def test_clean_name_str(self):
self.assertEqual(p13n.AlgorithmID.clean_name_str('usim-test'), 'usimtest')
self.assertEqual(p13n.AlgorithmID.clean_name_str('usim_test'), 'usimtest')
self.assertEqual(p13n.AlgorithmID.clean_name_str('Milenage'), 'milenage')
self.assertEqual(p13n.AlgorithmID.clean_name_str('foo bar!'), 'foobar')
if __name__ == "__main__":
if '-u' in sys.argv:
update_expected_output = True
sys.argv.remove('-u')
unittest.main()
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
# (C) 2026 by sysmocom - s.f.m.c. GmbH
# All Rights Reserved
#
# Author: Philipp Maier <pmaier@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/>.
import unittest
import os
from pySim.profile import CardProfile
from pySim.ts_51_011 import CardProfileSIM
from pySim.ts_102_221 import CardProfileUICC
class TestDecodeSelectResponse_CardProfile(unittest.TestCase):
def decode_select_response(self, card_Profile: CardProfile, testcases: list[dict]):
for testcase in testcases:
resp_hex = testcase['resp_hex']
decoded = card_Profile.decode_select_response(resp_hex)
if testcase['decoded']:
self.assertEqual(decoded, testcase['decoded'])
else:
print("no testvector to compare against, assuming the following output is correct:")
print("resp_hex:", resp_hex)
print("decoded:", decoded)
def test_CardProfileSIM(self):
testcases = [
# MF
{"resp_hex" : "000000003f000100000000000981020c0400838a838a",
"decoded" : {'file_descriptor': {'file_descriptor_byte': {'file_type': 'mf'}}, 'proprietary_info': {'available_memory': 0}, 'file_id': '3f00', 'file_characteristics': '81', 'num_direct_child_df': 2, 'num_direct_child_ef': 12, 'num_chv_unblock_adm_codes': 4}},
# DF.TELECOM
{"resp_hex" : "000000007f100200000000000981000d0400838a838a",
"decoded" : {'file_descriptor': {'file_descriptor_byte': {'file_type': 'df'}}, 'proprietary_info': {'available_memory': 0}, 'file_id': '7f10', 'file_characteristics': '81', 'num_direct_child_df': 0, 'num_direct_child_ef': 13, 'num_chv_unblock_adm_codes': 4}},
# EF.MSISDN
{"resp_hex" : "000000346f40040011ffff0102011a",
"decoded" : {'file_descriptor': {'file_descriptor_byte': {'file_type': 'working_ef', 'structure': 'linear_fixed'}, 'record_len': 26, 'num_of_rec': 2}, 'proprietary_info': {}, 'file_id': '6f40', 'file_size': 52, 'access_conditions': '11ffff', 'life_cycle_status_int': 'creation'}},
# EF.ICCID
{"resp_hex" : "0000000a2fe204000cffff01020000",
"decoded" : {'file_descriptor': {'file_descriptor_byte': {'file_type': 'working_ef', 'structure': 'transparent'}}, 'proprietary_info': {}, 'file_id': '2fe2', 'file_size': 10, 'access_conditions': '0cffff', 'life_cycle_status_int': 'creation'}},
]
self.decode_select_response(CardProfileSIM, testcases)
def test_CardProfileUICC(self):
testcases = [
# MF
{"resp_hex" : "622c8202782183023f00a50c80017183040003a7388701018a01058b032f0601c60c90016083010183010a83010b",
"decoded" : {'file_descriptor': {'file_descriptor_byte': {'shareable': True, 'file_type': 'df', 'structure': 'no_info_given'}, 'record_len': None, 'num_of_rec': None}, 'file_identifier': b'?\x00', 'proprietary_information': {'uicc_characteristics': b'q', 'available_memory': 239416, 'supported_filesystem_commands': {'terminal_capability': True}}, 'life_cycle_status_integer': 'operational_activated', 'security_attrib_referenced': {'ef_arr_file_id': b'/\x06', 'ef_arr_record_nr': 1}, 'pin_status_template_do': [{'ps_do': b'`'}, {'key_reference': 1}, {'key_reference': 10}, {'key_reference': 11}]}},
# ADF.USIM
{"resp_hex" : "623d8202782183027fd0840ca0000000871002ff49ff0589a50c80017183040003a7388701018a01058b032f0601c60f90017083010183018183010a83010b",
"decoded" : {'file_descriptor': {'file_descriptor_byte': {'shareable': True, 'file_type': 'df', 'structure': 'no_info_given'}, 'record_len': None, 'num_of_rec': None}, 'file_identifier': b'\x7f\xd0', 'df_name': b'\xa0\x00\x00\x00\x87\x10\x02\xffI\xff\x05\x89', 'proprietary_information': {'uicc_characteristics': b'q', 'available_memory': 239416, 'supported_filesystem_commands': {'terminal_capability': True}}, 'life_cycle_status_integer': 'operational_activated', 'security_attrib_referenced': {'ef_arr_file_id': b'/\x06', 'ef_arr_record_nr': 1}, 'pin_status_template_do': [{'ps_do': b'p'}, {'key_reference': 1}, {'key_reference': 129}, {'key_reference': 10}, {'key_reference': 11}]}},
# ADF.ISIM
{"resp_hex" : "623d8202782183027fb0840ca0000000871004ff49ff0589a50c80017183040003a7388701018a01058b032f0601c60f90017083010183018183010a83010b",
"decoded" : {'file_descriptor': {'file_descriptor_byte': {'shareable': True, 'file_type': 'df', 'structure': 'no_info_given'}, 'record_len': None, 'num_of_rec': None}, 'file_identifier': b'\x7f\xb0', 'df_name': b'\xa0\x00\x00\x00\x87\x10\x04\xffI\xff\x05\x89', 'proprietary_information': {'uicc_characteristics': b'q', 'available_memory': 239416, 'supported_filesystem_commands': {'terminal_capability': True}}, 'life_cycle_status_integer': 'operational_activated', 'security_attrib_referenced': {'ef_arr_file_id': b'/\x06', 'ef_arr_record_nr': 1}, 'pin_status_template_do': [{'ps_do': b'p'}, {'key_reference': 1}, {'key_reference': 129}, {'key_reference': 10}, {'key_reference': 11}]}},
# EF.IMSI
{"resp_hex" : "62178202412183026f078a01058b036f060a80020009880138",
"decoded" : {'file_descriptor': {'file_descriptor_byte': {'shareable': True, 'file_type': 'working_ef', 'structure': 'transparent'}, 'record_len': None, 'num_of_rec': None}, 'file_identifier': b'o\x07', 'life_cycle_status_integer': 'operational_activated', 'security_attrib_referenced': {'ef_arr_file_id': b'o\x06', 'ef_arr_record_nr': 10}, 'file_size': 9, 'short_file_identifier': 7}},
# EF.ECC
{"resp_hex" : "621a82054221000e0283026fb78a01058b036f06088002001c880108",
"decoded" : {'file_descriptor': {'file_descriptor_byte': {'shareable': True, 'file_type': 'working_ef', 'structure': 'linear_fixed'}, 'record_len': 14, 'num_of_rec': 2}, 'file_identifier': b'o\xb7', 'life_cycle_status_integer': 'operational_activated', 'security_attrib_referenced': {'ef_arr_file_id': b'o\x06', 'ef_arr_record_nr': 8}, 'file_size': 28, 'short_file_identifier': 1}},
]
self.decode_select_response(CardProfileUICC, testcases)
if __name__ == "__main__":
unittest.main()
+45 -3
View File
@@ -21,7 +21,7 @@ import copy
from osmocom.utils import h2b, b2h from osmocom.utils import h2b, b2h
from pySim.esim.saip import * from pySim.esim.saip import *
from pySim.esim.saip.personalization import * from pySim.esim.saip import personalization
from pprint import pprint as pp from pprint import pprint as pp
@@ -55,14 +55,56 @@ class SaipTest(unittest.TestCase):
def test_personalization(self): def test_personalization(self):
"""Test some of the personalization operations.""" """Test some of the personalization operations."""
pes = copy.deepcopy(self.pes) pes = copy.deepcopy(self.pes)
params = [Puk1('01234567'), Puk2(98765432), Pin1('1111'), Pin2(2222), Adm1('11111111'), params = [personalization.Puk1('01234567'),
K(h2b('000102030405060708090a0b0c0d0e0f')), Opc(h2b('101112131415161718191a1b1c1d1e1f'))] personalization.Puk2(98765432),
personalization.Pin1('1111'),
personalization.Pin2(2222),
personalization.Adm1('11111111'),
personalization.K(h2b('000102030405060708090a0b0c0d0e0f')),
personalization.Opc(h2b('101112131415161718191a1b1c1d1e1f'))]
for p in params: for p in params:
p.validate() p.validate()
p.apply(pes) p.apply(pes)
# TODO: we don't actually test the results here, but we just verify there is no exception # TODO: we don't actually test the results here, but we just verify there is no exception
pes.to_der() 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): def test_constructor_encode(self):
"""Test that DER-encoding of PE created by "empty" constructor works without raising exception.""" """Test that DER-encoding of PE created by "empty" constructor works without raising exception."""
for cls in [ProfileElementMF, ProfileElementPuk, ProfileElementPin, ProfileElementTelecom, for cls in [ProfileElementMF, ProfileElementPuk, ProfileElementPin, ProfileElementTelecom,
+6 -8
View File
@@ -27,6 +27,7 @@ import pySim.ts_31_102
import pySim.ts_31_103 import pySim.ts_31_103
import pySim.ts_51_011 import pySim.ts_51_011
import pySim.sysmocom_sja2 import pySim.sysmocom_sja2
import pySim.sysmocom_sjs1
import pySim.gsm_r import pySim.gsm_r
import pySim.cdma_ruim import pySim.cdma_ruim
@@ -176,12 +177,11 @@ class TransRecEF_Test(unittest.TestCase):
def test_de_encode_record(self): def test_de_encode_record(self):
"""Test the decoder and encoder for a transparent record-oriented EF. Performs first a decoder """Test the decoder and encoder for a transparent record-oriented EF at the whole-file
test, and then re-encodes the decoded data, comparing the re-encoded data with the level. Performs first a decode test, then re-encodes and compares with the input.
initial input data.
Requires the given TransRecEF subclass to have a '_test_de_encode' attribute, 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: for c in self.classes:
name = get_qualified_name(c) name = get_qualified_name(c)
@@ -192,14 +192,12 @@ class TransRecEF_Test(unittest.TestCase):
encoded = t[0] encoded = t[0]
decoded = t[1] decoded = t[1]
logging.debug("Testing decode of %s", name) 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) self.assertEqual(decoded, re_dec)
# re-encode the decoded data # re-encode the decoded data
logging.debug("Testing re-encode of %s", name) logging.debug("Testing re-encode of %s", name)
re_enc = inst.encode_record_hex(re_dec, len(encoded)//2) re_enc = inst.encode_hex(re_dec, len(encoded)//2)
self.assertEqual(encoded.upper(), re_enc.upper()) 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): class TransparentEF_Test(unittest.TestCase):
+144
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()
+575 -1
View File
@@ -17,7 +17,10 @@
import unittest import unittest
import logging import logging
import hashlib
from types import SimpleNamespace
from osmocom.utils import b2h, h2b from osmocom.utils import b2h, h2b
from osmocom.tlv import bertlv_encode_len
from pySim.global_platform import * from pySim.global_platform import *
from pySim.global_platform.scp import * from pySim.global_platform.scp import *
@@ -283,6 +286,41 @@ class SCP03_Test_AES256_33(SCP03_Test, unittest.TestCase):
# FIXME: test auth with random (0x60) vs pseudo-random (0x70) challenge # FIXME: test auth with random (0x60) vs pseudo-random (0x70) challenge
class KeyComponentBlock_Test(unittest.TestCase):
"""Tests for the kcb of GP CardSpec v2.3
- Table 11-70 kcv that required padding, preceded by its clear-text length
- Table 11-71 no padding required"""
def setUp(self):
# SCP02 (3DES DEK, 8 byte blocks), same vectors as SCP02_Test
self.scp02 = SCP02(card_keys=ck_3des_70)
self.scp02.gen_init_update_apdu(host_challenge=h2b('40A62C37FA6304F8'))
self.scp02.parse_init_update_resp(h2b('00000000000000000000700200016B4524ABEE7CF32EA3838BC148F3'))
self.scp02.gen_ext_auth_apdu()
# SCP03 (AES DEK, 16 byte blocks), same vectors as SCP03_Test_AES128_11
self.scp03 = SCP03(card_keys=KEYSET_AES128)
self.scp03.gen_init_update_apdu(h2b('b13e5f938fc108c4'))
self.scp03.parse_init_update_resp(h2b('000000000000000000003003703eb51047495b249f66c484c1d2ef1948000002'))
self.scp03.gen_ext_auth_apdu(0x11)
def test_encrypt_decrypt_key(self):
for scp in (self.scp02, self.scp03):
bs = scp.sk.blocksize
for keylen in range(1, 3 * bs + 1):
with self.subTest(scp=type(scp).__name__, keylen=keylen):
key = bytes(range(keylen))
kcb = scp.encrypt_key(key)
if keylen % bs:
# Table 11-70: <length of clear key component> || <encrypted padded value>
self.assertEqual(kcb[0], keylen)
self.assertEqual((len(kcb) - 1) % bs, 0)
self.assertEqual(len(kcb) - 1, keylen + (bs - keylen % bs))
else:
# Table 11-71: only the encrypted key component value
self.assertEqual(len(kcb), keylen)
self.assertEqual(scp.decrypt_key(kcb), key)
class SCP03_KCV_Test(unittest.TestCase): class SCP03_KCV_Test(unittest.TestCase):
def test_kcv(self): def test_kcv(self):
self.assertEqual(compute_kcv('aes', KEYSET_AES128.enc), h2b('C35280')) self.assertEqual(compute_kcv('aes', KEYSET_AES128.enc), h2b('C35280'))
@@ -290,13 +328,549 @@ class SCP03_KCV_Test(unittest.TestCase):
self.assertEqual(compute_kcv('aes', KEYSET_AES128.dek), h2b('840DE5')) self.assertEqual(compute_kcv('aes', KEYSET_AES128.dek), h2b('840DE5'))
class PutKey_PSK_Test(unittest.TestCase):
"""Tests for the PUT KEY command data field encoding, in particular the PSK TLS ('85') key data
field defined by GlobalPlatform Amendment B (Remote Application Management over HTTP) Table 3-13."""
# the PUT KEY encoder we exercise
C = ADF_SD.AddlShellCommands
# SCP80 TLS-PSK example key from the do_put_key docstring (16 bytes)
PSK_CLEAR = h2b('303132333435363738393a3b3c3d3e3f')
# its DEK ciphertext + Table 3-13 KCV with SCP02 session set up below
PSK_CIPHERED = h2b('15abf1fe16ccc5aa13743394442942cd')
PSK_KCV = h2b('06125d') # = SHA-1(PSK_CLEAR)[:3]
def setUp(self):
# SCP02 with the same vectors as SCP02_Test, so that the whole PUT KEY data field is reproducible.
self.scp02 = SCP02(card_keys=ck_3des_70)
self.scp02.gen_init_update_apdu(host_challenge=h2b('40A62C37FA6304F8'))
self.scp02.parse_init_update_resp(h2b('00000000000000000000700200016B4524ABEE7CF32EA3838BC148F3'))
self.scp02.gen_ext_auth_apdu()
def test_psk_kcv_is_sha1(self):
# GP Amendment B Table 3-13: KCV = 3 most significant bytes of SHA-1(clear key)
self.assertEqual(compute_kcv('tls_psk', self.PSK_CLEAR), hashlib.sha1(self.PSK_CLEAR).digest()[:3])
self.assertEqual(compute_kcv('tls_psk', self.PSK_CLEAR), self.PSK_KCV)
def test_encode_psk_framing_golden(self):
# assert the exact Table 3-13 layout
# 85 | L1 | L2 | <ciphered> | 03 | <SHA-1(clear)[:3]>
clear = self.PSK_CLEAR
ciphered = h2b('aabbccddeeff00112233445566778899') # arbitrary 16-byte ciphertext
kcv = hashlib.sha1(clear).digest()[:3]
field = self.C.encode_key_data_psk(clear, ciphered, kcv)
# 85 L1 L2 <---------- ciphered -----------> 03 <-kcv->
self.assertEqual(b2h(field),'85' '11' '10' 'aabbccddeeff00112233445566778899' '03' + b2h(kcv))
self.assertEqual(b2h(field),'851110aabbccddeeff0011223344556677889903' + '06125d')
def test_psk_golden_over_scp02(self):
# Full PUT KEY data field (KVN 0x40 + single PSK key) enciphered with the SCP02 DEK.
keys = [{'key_type': 'tls_psk', 'clear_key': self.PSK_CLEAR,
'kcv': compute_kcv('tls_psk', self.PSK_CLEAR)}]
data = self.C.build_put_key_data(0x40, keys, self.scp02)
self.assertEqual(b2h(data),
'40' '85' '11' '10' + b2h(self.PSK_CIPHERED) + '03' + b2h(self.PSK_KCV))
def test_wrong_basic_format_differs(self):
# regression test, the generic "Basic format" does NOT match Table 3-13 for a PSK key
# rejected by card with with 6a88
wrong_basic = self.C.encode_key_data_basic('tls_psk', self.PSK_CIPHERED, b'')
right_psk = self.C.encode_key_data_psk(self.PSK_CLEAR, self.PSK_CIPHERED, self.PSK_KCV)
self.assertEqual(b2h(wrong_basic), '8510' + b2h(self.PSK_CIPHERED) + '00')
self.assertEqual(b2h(right_psk), '8511' '10' + b2h(self.PSK_CIPHERED) + '03' + b2h(self.PSK_KCV))
self.assertNotEqual(wrong_basic, right_psk)
def test_key_component_block_length_is_bertlv(self):
# GP CardSpec v2.3.1 Section 11.8.2.3.1: all lengths ofPUT KEY are always BER TLV coded
for kcb_len, exp_len_field in [(127, '7f'), (128, '8180'), (129, '8181'), (256, '820100')]:
with self.subTest(kcb_len=kcb_len):
kcb = bytes(kcb_len)
field = self.C.encode_key_data_basic('rsa_modulus_n', kcb, b'')
self.assertEqual(b2h(field), 'a2' + exp_len_field + b2h(kcb) + '00')
# 85 field of Amendment B Table 3-13 uses the same coding
# single byte inner length (clear key < 128) == block kcb_len bytes long
psk = self.C.encode_key_data_psk(bytes(120), bytes(kcb_len - 1), b'')
self.assertEqual(b2h(psk)[:2 + len(exp_len_field)], '85' + exp_len_field)
def test_basic_format_unchanged(self):
# as before
for kt, clear in [('des', h2b('404142434445464748494a4b4c4d4e4f')),
('aes', h2b('000102030405060708090a0b0c0d0e0f'))]:
ciph = self.scp02.encrypt_key(clear)
kcv = compute_kcv(kt, clear)
via_construct = build_construct(self.C.KeyDataBasic, {'key_type': kt, 'kcb': b2h(ciph), 'kcv': b2h(kcv)})
via_helper = self.C.encode_key_data_basic(kt, ciph, kcv)
self.assertEqual(via_helper, via_construct)
def test_psk_padding_no_double_length(self):
# A PSK key whose length is not a multiple of the DEK block size (DES: 8) is right-padded before
# ciphering. Table 3-13 states the clear key length (L2) in the '85' DO itself, so the ciphered
# key field is the bare cryptogram:
# - ciphered field == padded ciphertext (no duplicated length prefix),
# - clear key == first L2 bytes.
for keylen in (18, 20):
with self.subTest(keylen=keylen):
clear = bytes(range(keylen))
padded_len = keylen + (-keylen % 8)
field = self.C.build_put_key_data(0x40, [{'key_type': 'tls_psk', 'clear_key': clear,
'kcv': compute_kcv('tls_psk', clear)}], self.scp02)[1:]
self.assertEqual(field[0], 0x85)
l1 = field[1]
l2 = field[2]
self.assertEqual(l2, keylen) # single-byte BER length of clear key
ciphered = field[3:3 + (l1 - 1)] # value = L2 (1 byte) || ciphered key
self.assertEqual(len(ciphered), padded_len) # padded to the 8-byte DES block size
self.assertEqual(l1, 1 + padded_len) # no duplicated length prefix
self.assertEqual(self.scp02.dek_decrypt(ciphered)[:keylen], clear)
def test_psk_clear_key_is_not_padded_in_place(self):
# padding the bytearray in place would make L2 the padded length,
# then stored as key material and rejected thanks to the KCV
clear = h2b('000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d') # 30, not %8
kcv = compute_kcv('tls_psk', clear)
field = self.C.build_put_key_data(0x40, [{'key_type': 'tls_psk', 'clear_key': clear,
'kcv': kcv}], self.scp02)[1:]
self.assertEqual(len(clear), 30)
self.assertEqual(field[2], 30) # L2 == clear key length, not 32
self.assertEqual(self.scp02.dek_decrypt(field[3:3 + field[1] - 1])[:30], clear)
def test_kcv_suppressed(self):
# --suppress-key-check -> KCV length 00 and no KCV bytes
field = self.C.build_put_key_data(0x40, [{'key_type': 'tls_psk', 'clear_key': self.PSK_CLEAR,
'kcv': b''}], self.scp02)[1:]
self.assertEqual(b2h(field), '8511' '10' + b2h(self.PSK_CIPHERED) + '00')
def test_multikey_psk_plus_des_dek(self):
# load a PSK TLS key (KID 1, Amendment B format) together with its DES DEK
# (KID 2, Basic format) in one PUT KEY.
# Verify the concatenated data field parses back into the two components with proper type formats.
dek = h2b('404142434445464748494a4b4c4d4e4f')
keys = [{'key_type': 'tls_psk', 'clear_key': self.PSK_CLEAR, 'kcv': compute_kcv('tls_psk', self.PSK_CLEAR)},
{'key_type': 'des', 'clear_key': dek, 'kcv': compute_kcv('des', dek)}]
data = self.C.build_put_key_data(0x40, keys, self.scp02)
b = data
self.assertEqual(b[0], 0x40) # KVN
b = b[1:]
# component 1: PSK TLS (Table 3-13)
self.assertEqual(b[0], 0x85)
self.assertEqual(b[1], 0x11) # L1 = 17
self.assertEqual(b[2], 0x10) # L2 = 16 (clear key length)
self.assertEqual(b[3:3 + 16], self.PSK_CIPHERED)
self.assertEqual(b[3 + 16], 0x03) # KCV length
self.assertEqual(b[3 + 16 + 1:3 + 16 + 1 + 3], self.PSK_KCV)
b = b[3 + 16 + 1 + 3:]
# component 2: DES DEK (Basic format)
self.assertEqual(b[0], 0x80) # key type des
kcb_len = b[1]
self.assertEqual(kcb_len, 16)
self.assertEqual(b[2:2 + kcb_len], self.scp02.encrypt_key(dek))
b = b[2 + kcb_len:]
self.assertEqual(b[0], 0x03) # KCV length
self.assertEqual(b[1:1 + 3], compute_kcv('des', dek))
self.assertEqual(b[1 + 3:], b'') # no trailing bytes
def test_no_scp_leaves_key_clear(self):
# During personalization (no SCP) the key is not enciphered, framing still follows Table 3-13.
field = self.C.build_put_key_data(0x40, [{'key_type': 'tls_psk', 'clear_key': self.PSK_CLEAR,
'kcv': self.PSK_KCV}], None)[1:]
self.assertEqual(b2h(field), '8511' '10' + b2h(self.PSK_CLEAR) + '03' + b2h(self.PSK_KCV))
class PutKey_Length_Test(unittest.TestCase):
"""Tests for the length of the PUT KEY command APDU. Lc of GP CardSpec v2.3 Table 11-64 is a
single byte, so an oversized key data field cannot be sent."""
class PutKeyOnly(ADF_SD.AddlShellCommands):
"""ADF_SD.AddlShellCommands with a canned scc to drive put_key()"""
def __init__(self, scp=None, max_cmd_len=255):
super().__init__()
self.sent = []
self.scc = SimpleNamespace(scp=scp, max_cmd_len=max_cmd_len,
send_apdu_checksw=lambda pdu: (self.sent.append(pdu), ('', '9000'))[1])
@property
def _cmd(self):
return SimpleNamespace(lchan=SimpleNamespace(scc=self.scc))
# KVN, key type, two byte BER length of the key component block, KCV length; KCV suppressed
FRAMING = 1 + 1 + 2 + 1
@staticmethod
def key(nbytes: int):
return [{'key_type': 'rsa_modulus_n', 'clear_key': bytes(nbytes), 'kcv': b''}]
def test_lc_matches_data_field(self):
# largest key component block that still fits without a secure channel
sd = self.PutKeyOnly()
sd.put_key(0, 0x40, 1, self.key(255 - self.FRAMING))
apdu = sd.sent[0]
self.assertEqual(apdu[:8], '80D80001')
lc = int(apdu[8:10], 16)
self.assertEqual(lc, 255) # Lc ...
self.assertEqual(len(apdu[10:-2]) // 2, lc) # ... and it matches the actual data field
def test_oversized_key_data_raises(self):
# real world fat example: RSA-2048 modulus does not fit, led to 3 nibble Lc 106,
# which silently shifted and broke the whole APDU by half a byte.
sd = self.PutKeyOnly()
with self.assertRaises(ValueError) as ctx:
sd.put_key(0, 0x40, 1, self.key(256))
self.assertIn('262', str(ctx.exception))
self.assertIn('255', str(ctx.exception))
self.assertEqual(sd.sent, []) # nothing was sent to the card
def test_secure_channel_overhead_lowers_the_limit(self):
# scc.max_cmd_len shrinks by the C-MAC + encryption padding of active SCP
sd = self.PutKeyOnly(max_cmd_len=239)
sd.put_key(0, 0x40, 1, self.key(239 - self.FRAMING))
self.assertEqual(int(sd.sent[0][8:10], 16), 239)
with self.assertRaises(ValueError):
sd.put_key(0, 0x40, 1, self.key(239 - self.FRAMING + 1))
class Install_param_Test(unittest.TestCase): class Install_param_Test(unittest.TestCase):
def test_gen_install_parameters(self): def test_gen_install_parameters(self):
load_parameters = gen_install_parameters(256, 256, '010001001505000000000000000000000000') load_parameters = gen_install_parameters(256, 256, '010001001505000000000000000000000000')
self.assertEqual(load_parameters, 'c900ef1cc8020100c7020100ca12010001001505000000000000000000000000') self.assertEqual(load_parameters, 'c900ef1cc8020100c7020100ca12010001001505000000000000000000000000')
load_parameters = gen_install_parameters(None, None, '') load_parameters = gen_install_parameters()
self.assertEqual(load_parameters, 'c900') self.assertEqual(load_parameters, 'c900')
class SCP_Overhead_Test(unittest.TestCase):
"""SCP.overhead varies according to the current security level:
C-MAC + at level >= 3 the worst-case padding!
"""
def _scp02(self, security_level):
scp = SCP02(card_keys=ck_3des_70)
scp.sk = Scp02SessionKeys(0x0001, ck_3des_70)
scp.security_level = security_level
return scp
def _scp03(self, security_level, s_mode=8):
scp = SCP03(card_keys=KEYSET_AES128, s_mode=s_mode)
scp.sk = Scp03SessionKeys(KEYSET_AES128, b'\x00' * s_mode, b'\x11' * s_mode)
scp.security_level = security_level
return scp
def test_scp02(self):
self.assertEqual(self._scp02(0x00).overhead, 0) # no wrapping at all
self.assertEqual(self._scp02(0x01).overhead, 8) # C-MAC
self.assertEqual(self._scp02(0x03).overhead, 16) # C-MAC + C-DEC: pad80 to 8, largest fit 239
def test_scp03_s8(self):
self.assertEqual(self._scp03(0x00).overhead, 0)
self.assertEqual(self._scp03(0x01).overhead, 8)
self.assertEqual(self._scp03(0x03).overhead, 16) # pad80 to 16 within 247 -> 240, minus pad byte
self.assertEqual(self._scp03(0x33).overhead, 16) # R-MAC/R-ENC add no *command* overhead
def test_scp03_s16(self):
self.assertEqual(self._scp03(0x01, s_mode=16).overhead, 16)
self.assertEqual(self._scp03(0x03, s_mode=16).overhead, 32) # pad80 to 16 within 239 -> 224, minus pad byte
class SCP_Lc_Limit_Test_Base(unittest.TestCase):
"""Test wrap_cmd_apdu() boundary handling: data of (255 - overhead) must produce Lc <= 255 else ValueError"""
def _load_apdu(self, data_len):
return h2b('80E80000') + bytes([data_len]) + b'\xa5' * data_len
def _check_boundary(self, scp):
fits = 255 - scp.overhead
wrapped = scp.wrap_cmd_apdu(self._load_apdu(fits))
self.assertLessEqual(wrapped[4], 255)
self.assertEqual(len(wrapped), 5 + wrapped[4]) # case #3: header + Lc bytes, no Le
with self.assertRaises(ValueError) as ctx:
scp.wrap_cmd_apdu(self._load_apdu(fits + 1))
self.assertIn('Lc', str(ctx.exception))
class SCP02_Lc_Limit_Test(SCP_Lc_Limit_Test_Base):
"""Same session vectors as SCP02_Auth_Test"""
def setUp(self):
self.scp02 = SCP02(card_keys=ck_3des_70)
self.scp02.gen_init_update_apdu(host_challenge=h2b('40A62C37FA6304F8'))
self.scp02.parse_init_update_resp(h2b('00000000000000000000700200016B4524ABEE7CF32EA3838BC148F3'))
self.scp02.gen_ext_auth_apdu()
def test_cmac_only(self):
self.scp02.security_level = 0x01
self._check_boundary(self.scp02) # 247 fits, 248 raises
def test_cmac_cdec(self):
self.scp02.security_level = 0x03
self._check_boundary(self.scp02) # 239 fits (-> Lc 248), 240 raises (would be 256)
def test_cmac_cdec_wrapped_lc(self):
# my actual failing case: 240 bytes at level 3
self.scp02.security_level = 0x03
wrapped = self.scp02.wrap_cmd_apdu(self._load_apdu(239))
self.assertEqual(wrapped[4], 248) # 239 -> pad80 -> 240 ciphertext + 8 mac
class SCP03_Lc_Limit_Test(SCP_Lc_Limit_Test_Base):
"""Session keys derived directly"""
def _scp03(self, security_level, s_mode):
scp = SCP03(card_keys=KEYSET_AES128, s_mode=s_mode)
scp.sk = Scp03SessionKeys(KEYSET_AES128, b'\x00' * s_mode, b'\x11' * s_mode)
scp.security_level = security_level
return scp
def test_s8_cmac_only(self):
self._check_boundary(self._scp03(0x01, 8)) # 247 fits, 248 raises
def test_s8_cmac_cdec(self):
self._check_boundary(self._scp03(0x03, 8)) # 239 fits, 240 raises
def test_s16_cmac_only(self):
self._check_boundary(self._scp03(0x01, 16)) # 239 fits, 240 raises
def test_s16_cmac_cdec(self):
self._check_boundary(self._scp03(0x03, 16)) # 223 fits, 224 raises
class _FakeSccForLoad:
"""mock lchan.scc: records LOAD APDUs, optionally wrapping them through a real SCP
instance first where the Lc overflow used to blow up"""
def __init__(self, max_cmd_len=255, scp=None):
self.max_cmd_len = max_cmd_len
self.scp = scp
self.sent = []
self.wrapped = []
def send_apdu_checksw(self, apdu, sw='9000'):
self.sent.append(apdu.lower())
if self.scp:
self.wrapped.append(self.scp.wrap_cmd_apdu(h2b(apdu)))
return ('', '9000')
class Load_ChunkLen_Test(unittest.TestCase):
"""ADF_SD.load() chunking: block size must use scc.max_cmd_len"""
payload = b'\xaa' * 500 # actual real world case LOAD TLV: C4 + 8201f4 + 500 = 504 total
def _sd(self, scc):
cmd = type('_Cmd', (), {'lchan': type('_Lchan', (), {'scc': scc})(),
'poutput': lambda self, *args: None})()
# cmd2 CommandSet has a r/o _cmd property -> shadow it
_SD = type('_SD', (ADF_SD.AddlShellCommands,), {'_cmd': cmd})
return _SD.__new__(_SD)
def _blocks(self, scc):
"""Get (p1, p2, lc) from LOAD APDU"""
for apdu in scc.sent:
self.assertEqual(apdu[0:4], '80e8')
yield int(apdu[4:6], 16), int(apdu[6:8], 16), int(apdu[8:10], 16)
def test_default_no_scp(self):
"""Without SCP the old 240 byte block size is kept, no idea what else might rely on this number"""
scc = _FakeSccForLoad(max_cmd_len=255)
self._sd(scc).load(self.payload)
blocks = list(self._blocks(scc))
self.assertEqual([b[2] for b in blocks], [240, 240, 24])
self.assertEqual([b[0] for b in blocks], [0x00, 0x00, 0x80]) # P1: last block flagged
self.assertEqual([b[1] for b in blocks], [0, 1, 2]) # P2: block num
def test_default_scp02_level3(self):
"""max_cmd_len 239 (SCP02 lvl 3) squeezes the blocks"""
scc = _FakeSccForLoad(max_cmd_len=239)
self._sd(scc).load(self.payload)
self.assertEqual([b[2] for b in list(self._blocks(scc))], [239, 239, 26])
def test_explicit_chunk_len(self):
scc = _FakeSccForLoad(max_cmd_len=255)
self._sd(scc).load(self.payload, chunk_len=100)
self.assertEqual([b[2] for b in list(self._blocks(scc))], [100] * 5 + [4])
def test_explicit_chunk_len_too_large(self):
scc = _FakeSccForLoad(max_cmd_len=239)
with self.assertRaises(ValueError):
self._sd(scc).load(self.payload, chunk_len=240)
self.assertEqual(scc.sent, []) # nothing sent!
def test_explicit_chunk_len_zero(self):
scc = _FakeSccForLoad(max_cmd_len=255)
with self.assertRaises(ValueError):
self._sd(scc).load(self.payload, chunk_len=0)
def test_end_to_end_scp02_level3(self):
"""original failure: 286 byte CAP + SCP02 lvl 3"""
scp02 = SCP02(card_keys=ck_3des_70)
scp02.gen_init_update_apdu(host_challenge=h2b('40A62C37FA6304F8'))
scp02.parse_init_update_resp(h2b('00000000000000000000700200016B4524ABEE7CF32EA3838BC148F3'))
scp02.gen_ext_auth_apdu()
scp02.security_level = 0x03
scc = _FakeSccForLoad(max_cmd_len=255 - scp02.overhead, scp=scp02)
self._sd(scc).load(b'\x5a' * 286)
self.assertEqual(len(scc.sent), 2) # 289 byte TLV in blocks of 239
for wrapped in scc.wrapped:
self.assertLessEqual(wrapped[4], 255)
# Real Card Data (GET DATA '66'), as returned by sja5 + euicc
CARD_DATA_V211 = ('6631732f06072a864886fc6b01600c060a2a864886fc6b0202010163090607'
'2a864886fc6b03640b06092a864886fc6b040215')
CARD_DATA_V22 = ('663b733906072a864886fc6b01600b06092a864886fc6b020202630906072a86'
'4886fc6b03640b06092a864886fc6b040370640b06092a864886fc6b04810400')
class _FakeScc:
"""mock lchan.scc: replays scripted (data, sw) pairs + records the APDUs sent."""
def __init__(self, responses, card_data=CARD_DATA_V211):
self._responses = list(responses)
self._card_data = card_data
self.sent = []
def get_data(self, cla, tag):
if self._card_data is None:
raise SwMatchError('6a88', '9000')
return self._card_data, '9000'
def send_apdu(self, apdu):
self.sent.append(apdu.lower())
if not self._responses:
raise AssertionError('get_status sent unexpected APDU: %s' % apdu)
return self._responses.pop(0)
class GpVersion_Test(unittest.TestCase):
"""GP version from Card Recognition Data, which v2.1.1/v2.3.1 section 7.4.1.3
require to be present. The OID under tag 60 is {globalPlatform 2 v...}."""
def test_decode_real_cards(self):
self.assertEqual(decode_gp_version(h2b(CARD_DATA_V211)), (2, 1, 1))
self.assertEqual(decode_gp_version(h2b(CARD_DATA_V22)), (2, 2))
def test_unknown_oid_is_none(self):
self.assertIsNone(decode_gp_version(h2b('66097307060512345678')))
def test_tag_lists_follow_the_spec_tables(self):
"""table 11-36 applications, table 11-37 for load files"""
self.assertEqual(b2h(get_status_tag_list('isd')), '5c074f9f70c5cfc4cc')
self.assertEqual(b2h(get_status_tag_list('applications')), '5c074f9f70c5cfc4cc')
self.assertEqual(b2h(get_status_tag_list('files')), '5c054f9f70cecc')
self.assertEqual(b2h(get_status_tag_list('files_and_modules')), '5c064f9f70ce84cc')
# C5 never load files, 84 never applications
self.assertNotIn('c5', b2h(get_status_tag_list('files')))
self.assertNotIn('84', b2h(get_status_tag_list('applications'))[4:])
class GetStatus_Pagination_Test(unittest.TestCase):
"""GPC v2.3.1 section 11.4.3.2 table 11-38 GET STATUS pagination test
Card answers 6310 when further matches are pending; command reissued with
P2 bit 1 "next occurrence" set. Tied to T=0 handling pySim/transport, which
used to swallow that 6310 and replied with GET RESPONSE, so page 2 was never fetched."""
ENTRY_1 = 'e3074f05a000000151'
ENTRY_2 = 'e3074f05a000000152'
def _sd(self, responses, card_data=CARD_DATA_V211):
scc = _FakeScc(responses, card_data)
cmd = type('_Cmd', (), {'lchan': type('_Lchan', (), {'scc': scc})()})()
# cmd2 strikes again, CommandSet exposes _cmd as a read only property, needs shadowing
_SD = type('_SD', (ADF_SD.AddlShellCommands,), {'_cmd': cmd})
return _SD.__new__(_SD), scc
def _aids(self, grd_list):
return [b2h(grd.to_dict()['gp_registry_related_data'][0]['application_aid']) for grd in grd_list]
def test_single_page(self):
sd, scc = self._sd([(self.ENTRY_1, '9000')])
grd_list = sd.get_status('applications')
self.assertEqual(scc.sent, ['80f24002024f0000'])
self.assertEqual(self._aids(grd_list), ['a000000151'])
def test_two_pages(self):
"""6310 -> reissue with P2 bit 1 set -> 9000, both pages in result"""
sd, scc = self._sd([(self.ENTRY_1, '6310'), (self.ENTRY_2, '9000')])
grd_list = sd.get_status('applications')
self.assertEqual(scc.sent, ['80f24002024f0000',
'80f24003024f0000'])
self.assertEqual(self._aids(grd_list), ['a000000151', 'a000000152'])
def test_three_pages_keep_p2_next_occurrence(self):
sd, scc = self._sd([(self.ENTRY_1, '6310'), (self.ENTRY_2, '6310'), (self.ENTRY_1, '9000')])
grd_list = sd.get_status('applications')
self.assertEqual([a[6:8] for a in scc.sent], ['02', '03', '03'])
self.assertEqual(len(grd_list), 3)
def test_no_match_returns_empty(self):
"""6A88 "referenced data not found" is empty result not failure."""
sd, _scc = self._sd([('', '6a88')])
self.assertEqual(sd.get_status('applications'), [])
def test_v211_card_gets_no_tag_list(self):
"""v2.1.1 section 9.4.2.3 has no tag list,not send a tag list"""
sd, scc = self._sd([(self.ENTRY_1, '9000')], card_data=CARD_DATA_V211)
sd.get_status('applications')
self.assertEqual(scc.sent, ['80f24002024f0000'])
self.assertNotIn('5c', scc.sent[0][8:])
def test_v22_card_gets_a_tag_list(self):
sd, scc = self._sd([(self.ENTRY_1, '9000')], card_data=CARD_DATA_V22)
sd.get_status('applications')
self.assertEqual(scc.sent, ['80f240020b4f005c074f9f70c5cfc4cc00'])
def test_unknown_version_gets_no_tag_list(self):
"""If the card will not say, assume the conservative form that works everywhere."""
sd, scc = self._sd([(self.ENTRY_1, '9000')], card_data=None)
sd.get_status('applications')
self.assertEqual(scc.sent, ['80f24002024f0000'])
def test_v22_card_rejecting_tag_list_falls_back(self):
"""card announcing v2.2+ that still answers 6A80 to the tag list."""
sd, scc = self._sd([('', '6a80'), (self.ENTRY_1, '9000')], card_data=CARD_DATA_V22)
grd_list = sd.get_status('applications')
self.assertEqual(scc.sent, ['80f240020b4f005c074f9f70c5cfc4cc00',
'80f24002024f0000'])
self.assertEqual(self._aids(grd_list), ['a000000151'])
def test_aid_search_qualifier(self):
sd, scc = self._sd([(self.ENTRY_1, '9000')])
sd.get_status('applications', 'a000000087')
self.assertEqual(scc.sent, ['80f24002074f05a00000008700'])
def test_6a80_is_reported_on_a_v211_card(self):
"""no tag list -> 6A80 is error"""
sd, _scc = self._sd([('', '6a80')], card_data=CARD_DATA_V211)
with self.assertRaises(SwMatchError) as ctx:
sd.get_status('applications')
self.assertEqual(ctx.exception.sw_actual, '6a80')
def test_unexpected_sw_is_not_silently_truncated(self):
"""partial is not complete result"""
sd, _scc = self._sd([(self.ENTRY_1, '6310'), ('', '6982')])
with self.assertRaises(SwMatchError) as ctx:
sd.get_status('applications')
self.assertEqual(ctx.exception.sw_actual, '6982')
def test_v22_card_answering_6a88_to_the_tag_list_falls_back(self):
"""6A88 is the other GET STATUS error condition of table 11-39, section 11.4.2.3
says we may get get an error status. 6A88 to the tag-list attempt should be retried
without it or we get nothing"""
sd, scc = self._sd([('', '6a88'), (self.ENTRY_1, '9000')], card_data=CARD_DATA_V22)
grd_list = sd.get_status('applications')
self.assertEqual(scc.sent, ['80f240020b4f005c074f9f70c5cfc4cc00',
'80f24002024f0000'])
self.assertEqual(self._aids(grd_list), ['a000000151'])
def test_v22_card_with_a_genuinely_empty_subset(self):
"""...and when the retry answers 6A88, the list really is empty."""
sd, scc = self._sd([('', '6a88'), ('', '6a88')], card_data=CARD_DATA_V22)
self.assertEqual(sd.get_status('applications'), [])
self.assertEqual(len(scc.sent), 2)
def test_6a88_after_a_page_keeps_that_page(self):
"""6A88 is "no more matches" after we have data, we're done"""
sd, _scc = self._sd([(self.ENTRY_1, '6310'), ('', '6a88')], card_data=CARD_DATA_V22)
self.assertEqual(self._aids(sd.get_status('applications')), ['a000000151'])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+33
View File
@@ -20,11 +20,20 @@
import unittest import unittest
import logging import logging
import cmd2
from packaging import version
from pySim.log import PySimLogger from pySim.log import PySimLogger
import io import io
import sys import sys
from inspect import currentframe, getframeinfo from inspect import currentframe, getframeinfo
if version.parse(cmd2.__version__) >= version.parse("3.0.0"):
from cmd2 import Color # pylint: disable=no-name-in-module
YELLOW = Color.YELLOW
else: # cmd2>=2.6.2
from cmd2 import Fg # pylint: disable=no-name-in-module
YELLOW = Fg.YELLOW
log = PySimLogger.get(__name__) log = PySimLogger.get(__name__)
TEST_MSG_DEBUG = "this is a debug message" TEST_MSG_DEBUG = "this is a debug message"
@@ -37,6 +46,17 @@ expected_message = None
class PySimLogger_Test(unittest.TestCase): class PySimLogger_Test(unittest.TestCase):
def setUp(self):
# PySimLogger.setup() is global, so a print callback left installed here fires for
# every PySimLogger message emitted by any test module that runs later in the same process
# ... where it asserts against a stale 'expected_message' and fails a test that has nothing
# to do with logging. Great fun!
# Restore before each test.
saved = (PySimLogger.print_callback, PySimLogger.verbose)
def _restore():
PySimLogger.print_callback, PySimLogger.verbose = saved
self.addCleanup(_restore)
def __test_01_safe_defaults_one(self, callback, message:str): def __test_01_safe_defaults_one(self, callback, message:str):
# When log messages are sent to an unconfigured PySimLogger class, we expect the unmodified message being # When log messages are sent to an unconfigured PySimLogger class, we expect the unmodified message being
# logged to stdout, just as if it were printed via a normal print() statement. # logged to stdout, just as if it were printed via a normal print() statement.
@@ -117,5 +137,18 @@ class PySimLogger_Test(unittest.TestCase):
expected_message = "CRITICAL: " + TEST_MSG_CRITICAL expected_message = "CRITICAL: " + TEST_MSG_CRITICAL
log.critical(TEST_MSG_CRITICAL) log.critical(TEST_MSG_CRITICAL)
def test_05_color(self):
# A color is either
# - raw escape sequence
# - cmd2 color object
global expected_message
expected_message = "\033[33mWARNING: " + TEST_MSG_WARNING + "\033[0m"
PySimLogger.setup(self._test_print_callback, {logging.WARN: "\033[33m"})
log.warning(TEST_MSG_WARNING)
PySimLogger.setup(self._test_print_callback, {logging.WARN: YELLOW})
log.warning(TEST_MSG_WARNING) # don't leak cmd2 Color StrEnum
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
+287
View File
@@ -300,5 +300,292 @@ class SmsOtaTestCase(OtaTestCase):
self.assertEqual(d.last_status_word, t['response']['last_status_word']) self.assertEqual(d.last_status_word, t['response']['last_status_word'])
self.assertEqual(d.last_response_data, t['response']['last_response_data']) self.assertEqual(d.last_response_data, t['response']['last_response_data'])
######################################################################
# Expanded Remote Application data format (ETSI TS 102 226 Section 5.2)
######################################################################
class BerTlvLengthTestCase(unittest.TestCase):
"""The definite-length BER-TLV length field (ISO/IEC 8825-1) used by the
expanded format, incl. the multi-byte (>127) forms (0x81xx / 0x82xxxx)."""
def test_roundtrip(self):
# (length value, expected encoded bytes)
vectors = [
(0, '00'),
(1, '01'),
(127, '7f'),
(128, '8180'),
(198, '81c6'), # big ~198 byte GET STATUS registry from a sja5
(255, '81ff'),
(256, '820100'),
(65535, '82ffff'),
]
for length, encoded in vectors:
with self.subTest(length=length):
built = BerTlvLen.build(length)
self.assertEqual(b2h(built), encoded)
self.assertEqual(BerTlvLen.parse(built), length)
class ExpandedCmdTestCase(unittest.TestCase):
"""Command Scripting template TS 102 226 5.2.1"""
def test_single_capdu_golden(self):
# GP GET STATUS, Le=00, TS 102 226 5.2.1.1 R-APDU
out = encode_expanded_cmd(h2b('80f24002024f0000'))
# aa = TS 101 220 table 7.18 Command Scripting template tag
# 0a = length 10
# 22 = TS 101 220 table 7.19 C-APDU tag
# 08 = length
# + C-APDU
self.assertEqual(b2h(out), 'aa0a220880f24002024f0000')
def test_multi_capdu_golden(self):
out = encode_expanded_cmd([h2b('80f24002024f0000'), h2b('00a40004023f0000')])
self.assertEqual(b2h(out), 'aa14220880f24002024f0000220800a40004023f0000')
def test_multibyte_length_golden(self):
# C-APDU: 4 header + 1 Lc + 195 data = 200 bytes.
# 200 byte C-APDU forces long form BER lengths:
# C-APDU TLV, 200 -> 81c8 + template 203 -> 81cb
capdu = h2b('80f24000') + bytes([195]) + bytes(range(195))
self.assertEqual(len(capdu), 200)
out = encode_expanded_cmd(capdu)
# aa 81 cb | 22 81 c8 | <200 byte capdu>
self.assertEqual(b2h(out[:6]), 'aa81cb2281c8')
self.assertEqual(out[6:], capdu)
def test_roundtrip(self):
for apdus in [[h2b('80f24002024f0000')],
[h2b('00a40004023f00'), h2b('80f24002024f0000')],
[h2b('00'*250)]]:
with self.subTest(n=len(apdus)):
out = encode_expanded_cmd(apdus)
parsed = ExpandedCmd.parse(out)
self.assertEqual([h2b(c.c_apdu) for c in parsed.commands], apdus)
class ExpandedRespTestCase(unittest.TestCase):
"""Decoding of the Response Scripting template (TS 102 226 5.2.2)."""
def test_registry_golden(self):
# real card case: GET STATUS returns a ~198 byte registry TLV + SW 9000
# R-APDU = 198 data + 2 SW = 200/81c8
# 'number of executed' TLV 80 01 01.
registry = bytes(range(198))
data = ExpandedRemoteResp.build(dict(body=dict(
num_executed=dict(number_of_commands=1),
responses=[dict(r_apdu=dict(response_data=b2h(registry), status_word='9000'))])))
# ab | 81 ce | 80 01 01 | 23 81 c8 | <198 data> 90 00
self.assertEqual(b2h(data[:9]), 'ab81ce8001012381c8')
dec = decode_expanded_resp(data)
self.assertEqual(dec.number_of_commands, 1)
self.assertEqual(len(dec.commands), 1)
self.assertEqual(dec.last_status_word, '9000')
self.assertEqual(dec.last_response_data, b2h(registry))
def test_status_only_golden(self):
# last command, no response data, SW 6132
data = ExpandedRemoteResp.build(dict(body=dict(
num_executed=dict(number_of_commands=1),
responses=[dict(r_apdu=dict(response_data='', status_word='6132'))])))
self.assertEqual(b2h(data), 'ab0780010123026132')
dec = decode_expanded_resp(data)
self.assertEqual(dec.last_status_word, '6132')
self.assertEqual(dec.last_response_data, '')
def test_multi_command(self):
data = ExpandedRemoteResp.build(dict(body=dict(
num_executed=dict(number_of_commands=2),
responses=[dict(r_apdu=dict(response_data='6f21', status_word='9000')),
dict(r_apdu=dict(response_data='', status_word='6a82'))])))
dec = decode_expanded_resp(data)
self.assertEqual(dec.number_of_commands, 2)
self.assertEqual([(c.status_word, c.response_data) for c in dec.commands],
[('9000', '6f21'), ('6a82', '')])
# last == final R-APDU, error status included
self.assertEqual(dec.last_status_word, '6a82')
self.assertEqual(dec.last_response_data, '')
def test_bad_format(self):
# ab | 06 | 80 01 01 | 90 01 01
data = h2b('ab06800101900101')
dec = decode_expanded_resp(data)
self.assertEqual(str(dec.bad_format), 'unknown_tag')
self.assertIsNone(dec.last_status_word)
def test_immediate_action_error(self):
# ab | 06 | 80 01 01 | 81 01 01
data = h2b('ab06800101810101')
dec = decode_expanded_resp(data)
self.assertEqual(str(dec.immediate_action_response), 'suspension_error')
def test_script_chaining_error(self):
# ab | 06 | 80 01 01 | 83 01 02
data = h2b('ab06800101830102')
dec = decode_expanded_resp(data)
self.assertEqual(str(dec.script_chaining_response), 'not_supported')
def test_truncation_is_flagged(self):
"""TS 102 226 5.2.1.1: SW 62F1 means the C-APDU response data was truncated, and
"this shall terminate the processing of the command list"
halves are invisible in the R-APDU list, truncated + aborted script must not pass as complete"""
# second command truncated -> processing stopped at that point
data = ExpandedRemoteResp.build(dict(body=dict(
num_executed=dict(number_of_commands=2),
responses=[dict(r_apdu=dict(response_data='6f21', status_word='9000')),
dict(r_apdu=dict(response_data='aabb', status_word='62f1'))])))
dec = decode_expanded_resp(data)
self.assertTrue(dec.truncated)
self.assertEqual(dec.last_status_word, '62f1')
def test_untruncated_response_is_not_flagged(self):
data = ExpandedRemoteResp.build(dict(body=dict(
num_executed=dict(number_of_commands=1),
responses=[dict(r_apdu=dict(response_data='6f21', status_word='9000'))])))
self.assertFalse(decode_expanded_resp(data).truncated)
# 62xx that is not 62F1 is warning, not truncation
data = ExpandedRemoteResp.build(dict(body=dict(
num_executed=dict(number_of_commands=1),
responses=[dict(r_apdu=dict(response_data='', status_word='6282'))])))
self.assertFalse(decode_expanded_resp(data).truncated)
class ExpandedIndefiniteTestCase(unittest.TestCase):
"""Indef len coding of expanded format TS 102 226 tables
5.2a/5.10a; cmd tag AE, resp tag AF.
Golden vectors captured from live eUICC over SCP81/HTTPS."""
def test_cmd_single_golden(self):
# RAM GET DATA 80CA00E000 -> AE 80 | 22 05 80ca00e000 | 00 00
out = encode_expanded_cmd(h2b('80ca00e000'), length_coding='indefinite')
self.assertEqual(b2h(out), 'ae80220580ca00e0000000')
def test_cmd_multi_golden(self):
# RFM: SELECT MF / SELECT EF.ICCID / READ BINARY, each in one C-APDU
# TLV, wrapped in indef Command Scripting template
out = encode_expanded_cmd([h2b('00a4000c023f00'), h2b('00a4000c022fe2'),
h2b('00b000000a')], length_coding='indefinite')
self.assertEqual(b2h(out),
'ae80220700a4000c023f00220700a4000c022fe2220500b000000a0000')
def test_cmd_definite_is_default(self):
# The default/explicit definite keeps the tag AA
self.assertEqual(encode_expanded_cmd(h2b('80ca00e000')),
encode_expanded_cmd(h2b('80ca00e000'), length_coding='definite'))
self.assertEqual(b2h(encode_expanded_cmd(h2b('80ca00e000'))), 'aa07220580ca00e000')
def test_cmd_invalid_length_coding(self):
with self.assertRaises(ValueError):
encode_expanded_cmd(h2b('80ca00e000'), length_coding='bogus')
def test_resp_rfm_golden(self):
# AF 80 | 23 02 9000 | 23 02 9000 | 23 0c <ICCID> 9000 | 00 00
# indef res has no "number of executed" TLV.
dec = decode_expanded_resp(h2b(
'af80' '23029000' '23029000' '230c988812010000408608149000' '0000'))
self.assertEqual(len(dec.commands), 3)
self.assertEqual([(c.status_word, c.response_data) for c in dec.commands],
[('9000', ''), ('9000', ''), ('9000', '98881201000040860814')])
self.assertEqual(dec.last_status_word, '9000')
self.assertEqual(dec.last_response_data, '98881201000040860814')
# report the R-APDU count instead
self.assertEqual(dec.number_of_commands, 3)
def test_resp_ram_golden(self):
# RAM GET DATA: R-APDU carrying the SD key info TLV + SW.
resp = ('af80' '2334e030c00403308810c00402308810c00401308810c00402408810'
'c00401408510c00403018810c00402018810c004010188109000' '0000')
dec = decode_expanded_resp(h2b(resp))
self.assertEqual(len(dec.commands), 1)
self.assertEqual(dec.last_status_word, '9000')
self.assertEqual(dec.last_response_data,
'e030c00403308810c00402308810c00401308810c00402408810'
'c00401408510c00403018810c00402018810c00401018810')
def test_resp_truncated_is_rejected(self):
# last byte chopped off: the end-of-contents marker is incomplete
good = h2b('af80' '23029000' '230c988812010000408608149000' '0000')
for cut in (1, 2, 3):
with self.subTest(cut=cut):
with self.assertRaises(ValueError):
decode_expanded_resp(good[:-cut])
def test_resp_definite_still_parses(self):
# same decoder still handles the definite AB template.
dec = decode_expanded_resp(h2b('ab0780010123029000'))
self.assertEqual(dec.number_of_commands, 1)
self.assertEqual(dec.last_status_word, '9000')
def test_resp_indefinite_bad_format(self):
# AF 80 | 90 01 01 | 00 00 unknown_tag no R-APDU
dec = decode_expanded_resp(h2b('af8090010100 00'.replace(' ', '')))
self.assertEqual(str(dec.bad_format), 'unknown_tag')
self.assertIsNone(dec.last_status_word)
def test_resp_missing_eoc_raises(self):
# AF 80 | 23 02 9000 without end-of-contents.
with self.assertRaises(ValueError):
decode_expanded_resp(h2b('af8023029000'))
class ExpandedSmsPipelineTestCase(unittest.TestCase):
"""expanded format + TS 102 225 SMS security witj 3DES keyset,
to ensure remote_format does not affect the compact path"""
def __init__(self, methodName='runTest', **kwargs):
super().__init__(methodName, **kwargs)
self.od = OtaKeyset(algo_crypt='triple_des_cbc2', kic_idx=3,
kic=h2b('C21DD66ACAC13CB3BC8B331B24AFB57B'),
algo_auth='triple_des_cbc2', kid_idx=3,
kid=h2b('12110C78E678C25408233076AA033615'))
self.dialect = OtaDialectSms()
self.tar = h2b('000000')
def test_cmd_expanded_secured_roundtrip(self):
spi = SPI_CC_POR_CIPHERED_CC
enc = self.dialect.encode_cmd(self.od, self.tar, spi, h2b('80f24002024f0000'),
remote_format='expanded')
# decode_cmd returns opaque 'Command Scripting template'
dec_tar, dec_spi, dec_secured = self.dialect.decode_cmd(self.od, enc)
self.assertEqual(b2h(dec_tar), b2h(self.tar))
self.assertEqual(dec_spi, spi)
self.assertEqual(b2h(dec_secured), 'aa0a220880f24002024f0000')
def test_cmd_expanded_list(self):
spi = SPI_CC_POR_CIPHERED_CC
enc = self.dialect.encode_cmd(self.od, self.tar, spi,
[h2b('80f24002024f0000'), h2b('00a40004023f0000')],
remote_format='expanded')
_, _, dec_secured = self.dialect.decode_cmd(self.od, enc)
parsed = ExpandedCmd.parse(dec_secured)
self.assertEqual([c.c_apdu for c in parsed.commands],
['80f24002024f0000', '00a40004023f0000'])
def test_resp_expanded_plaintext(self):
# plaintext (u:nciphered + no CC) expanded response SMS
# containing a 198 byte GP registry + SW 9000 as above, decode it through decode_resp().
spi = SPI_CC_POR_UNCIPHERED_NOCC
registry = bytes(range(198))
secured = ExpandedRemoteResp.build(dict(body=dict(
num_executed=dict(number_of_commands=1),
responses=[dict(r_apdu=dict(response_data=b2h(registry), status_word='9000'))])))
rpl = 1 + 3 + 5 + 1 + 1 + len(secured) # RHL-STS + secured data
resp_body = rpl.to_bytes(2, 'big') + b'\x0a' + self.tar + b'\x00'*5 + b'\x00' + b'\x00' + secured
sms = b'\x02\x71\x00' + resp_body
r, dec = self.dialect.decode_resp(self.od, spi, sms, remote_format='expanded')
self.assertEqual(r.response_status, 'por_ok')
self.assertEqual(dec.number_of_commands, 1)
self.assertEqual(dec.last_status_word, '9000')
self.assertEqual(dec.last_response_data, b2h(registry))
def test_compact_still_default(self):
# no remote_format -> compact default
spi = SPI_CC_POR_UNCIPHERED_NOCC
r, d = self.dialect.decode_resp(self.od, spi, '027100000e0ab000110000000000000001612f')
self.assertEqual(d.number_of_commands, 1)
self.assertEqual(d.last_status_word, '612f')
self.assertEqual(d.last_response_data, '')
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+206
View File
@@ -0,0 +1,206 @@
#!/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 Paramtest(D):
mandatory = (
'param_source',
'n',
'expect',
)
optional = (
'expect_arg',
'csv_rows',
)
param_source: param_source.ParamSource
n: int
expect: object
expect_arg: object
csv_rows: object
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 = [
Paramtest(param_source=param_source.ConstantSource.from_str('123'),
n=3,
expect=expect_const,
expect_arg=('123', '123', '123')),
Paramtest(param_source=param_source.RandomDigitSource.from_str('12345'),
n=3,
expect=expect_random,
expect_arg={'digits': decimals,
'val_minlen': 5,
'val_maxlen': 5}),
Paramtest(param_source=param_source.RandomDigitSource.from_str('1..999'),
n=10,
expect=expect_random,
expect_arg={'digits': decimals,
'val_minlen': 1,
'val_maxlen': 3}),
Paramtest(param_source=param_source.RandomDigitSource.from_str('001..999'),
n=10,
expect=expect_random,
expect_arg={'digits': decimals,
'val_minlen': 3,
'val_maxlen': 3}),
Paramtest(param_source=param_source.RandomHexDigitSource.from_str('12345678'),
n=3,
expect=expect_random,
expect_arg={'digits': hexadecimals,
'val_minlen': 8,
'val_maxlen': 8}),
Paramtest(param_source=param_source.RandomHexDigitSource.from_str('0*8'),
n=3,
expect=expect_random,
expect_arg={'digits': hexadecimals,
'val_minlen': 8,
'val_maxlen': 8}),
Paramtest(param_source=param_source.RandomHexDigitSource.from_str('00*4'),
n=3,
expect=expect_random,
expect_arg={'digits': hexadecimals,
'val_minlen': 8,
'val_maxlen': 8}),
Paramtest(param_source=param_source.IncDigitSource.from_str('10001'),
n=3,
expect=expect_const,
expect_arg=('10001', '10002', '10003')),
Paramtest(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()
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
""" test for smpp-ota-tool SMS handling, specifically the multi part sms OTA response"""
# (C) 2026 by sysmocom - s.f.m.c. GmbH
# All Rights Reserved
#
# Author: Eric Wild <ewild@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/>.
import os.path
import importlib.util
import unittest
from osmocom.utils import h2b, b2h
from pySim.ota import OtaKeyset, OtaDialectSms, ExpandedRemoteResp
from pySim.sms import ConcatenatedSmsReassembler, UserDataHeader
# import the hyphenated contrib script as a module to get at SmppHandler
# why do people name python files like that? why does everything have to be so hard?
_TOOL_PATH = os.path.join(os.path.dirname(__file__), '..', '..', 'contrib', 'smpp-ota-tool.py')
_spec = importlib.util.spec_from_file_location('smpp_ota_tool', _TOOL_PATH)
smpp_ota_tool = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(smpp_ota_tool)
SmppHandler = smpp_ota_tool.SmppHandler
class _FakePdu:
"""Minimal mock for smpplib deliver_sm pdu."""
def __init__(self, short_message):
self.short_message = short_message
class MultipartRelayTestCase(unittest.TestCase):
"""message_received_handler must return the reassembled application
response and survive POR messages."""
# 3DES test keyset from tests/unittests/test_ota.py) used to make the
# handler happy. responses are plaintext, tests do not depend on keys.
def _handler(self, remote_format='expanded'):
h = object.__new__(SmppHandler)
h.client = None
h.ota_dialect = OtaDialectSms()
h.ota_keyset = OtaKeyset(algo_crypt='triple_des_cbc2', kic_idx=3,
kic=h2b('C21DD66ACAC13CB3BC8B331B24AFB57B'),
algo_auth='triple_des_cbc2', kid_idx=3,
kid=h2b('12110C78E678C25408233076AA033615'))
h.tar = h2b('000000')
# unciphered, no CC, PoR required
h.spi = {'counter': 'no_counter', 'ciphering': False, 'rc_cc_ds': 'no_rc_cc_ds',
'por_in_submit': False, 'por': 'por_required',
'por_shall_be_ciphered': False, 'por_rc_cc_ds': 'no_rc_cc_ds'}
h.remote_format = remote_format
h.reassembler = ConcatenatedSmsReassembler()
h.response = None
return h
@staticmethod
def _plaintext_resp_sms(secured: bytes, sts: int = 0x00) -> bytes:
"""Build a plaintext (unciphered, no-CC) OTA SMS response packet in the
canonical single-part form (UDH 02 71 00 + response packet)."""
rpl = 1 + 3 + 5 + 1 + 1 + len(secured) # RHL-STS + secured data
body = (rpl.to_bytes(2, 'big') + b'\x0a' + h2b('000000') + b'\x00' * 5
+ b'\x00' + bytes([sts]) + secured)
return b'\x02\x71\x00' + body
@staticmethod
def _expanded_secured(response_data_hex: str, sw: str = '9000') -> bytes:
return ExpandedRemoteResp.build(dict(body=dict(
num_executed=dict(number_of_commands=1),
responses=[dict(r_apdu=dict(response_data=response_data_hex, status_word=sw))])))
@staticmethod
def _fragment_2(tpud: bytes, ref: int, first_len: int):
"""Split 02 71 00 + body TP-UD into two SMS parts:
- part1 carries the OTA (0x71) IE
- part2 only concatenat IE
matches sja5 interaction"""
assert tpud[:3] == b'\x02\x71\x00'
body = tpud[3:]
ota_ie = {'iei': 0x71, 'length': 0, 'value': b''}
def concat(seq):
return {'iei': 0x00, 'length': 3, 'value': bytes([ref, 2, seq])}
p1 = UserDataHeader([concat(1), ota_ie]).to_bytes() + body[:first_len]
p2 = UserDataHeader([concat(2)]).to_bytes() + body[first_len:]
return p1, p2
# ground truth: TP-User-Data captured from a sja5
REAL_PART1 = h2b('070003010201710000e412000000df63afe4b06db21e2113be1be09e9b66f1c113ae841cca2d030064ec16b5b80ee5ce824604a4568109d25a82fb74a325df6f911bd0a4f858ece2c770039002c480269fc65953f5fd93ebbe528d97838bac4389a7303db2b073a37a9a1a51890457f41b49fc7905ce337e83449b65560501b8b845fe63339d557a928f2643')
REAL_PART2 = h2b('050003010202fd9c4e50ec40fb4427af518e9c08697405d91fbb6e9fa0b0935f48a560e15f2f3f27a2e44ef3a47280acce77f030fb70eb3df863c159177e2c0e3e53052fc7bb7ed171a491ded3ab7921861176a04305bc09fcf526c07bf6bb48a19e67cf18be5bc1')
REAL_REASSEMBLED = '02710000e412000000df63afe4b06db21e2113be1be09e9b66f1c113ae841cca2d030064ec16b5b80ee5ce824604a4568109d25a82fb74a325df6f911bd0a4f858ece2c770039002c480269fc65953f5fd93ebbe528d97838bac4389a7303db2b073a37a9a1a51890457f41b49fc7905ce337e83449b65560501b8b845fe63339d557a928f2643fd9c4e50ec40fb4427af518e9c08697405d91fbb6e9fa0b0935f48a560e15f2f3f27a2e44ef3a47280acce77f030fb70eb3df863c159177e2c0e3e53052fc7bb7ed171a491ded3ab7921861176a04305bc09fcf526c07bf6bb48a19e67cf18be5bc1'
def test_real_card_parts_reassemble(self):
"""two real card TP-UDs recombine into 233-byte single part packet:
UDH 02 71 00 + response packet"""
r = ConcatenatedSmsReassembler()
self.assertIsNone(r.add(self.REAL_PART1))
out = r.add(self.REAL_PART2)
self.assertEqual(len(out), 233)
self.assertEqual(b2h(out), self.REAL_REASSEMBLED)
def test_multipart_response_not_overwritten_by_por(self):
"""reassembled application response must survive the ENVELOPE
trailing POR which contains no R-APDU"""
registry = bytes(range(198))
app = self._plaintext_resp_sms(self._expanded_secured(b2h(registry)))
part1, part2 = self._fragment_2(app, ref=0x42, first_len=132)
# single part form must be too fat -> both parts must be concatenated
self.assertGreater(len(app), 140)
# ENVELOPE PoR: por_ok, but no app R-APDU
inline_por = self._plaintext_resp_sms(b'', sts=0x00)
h = self._handler()
# arrival order
self.assertIsNone(h.message_received_handler(_FakePdu(part1)))
h.message_received_handler(_FakePdu(part2))
h.message_received_handler(_FakePdu(inline_por))
# self.response must be app response, not the PoR!
self.assertIsNotNone(h.response)
res, decoded = h.response
self.assertEqual(res.response_status, 'por_ok')
self.assertIsNotNone(decoded)
self.assertEqual(decoded.last_response_data, b2h(registry))
self.assertEqual(decoded.last_status_word, '9000')
def test_undecodable_response_does_not_crash(self):
"""response the handler can't decode must not escape out of the poll()
loop which would kill the tool, it must be ignored"""
# por_ok with a not expanded 'secured data' -> expanded parse raises
bad = self._plaintext_resp_sms(h2b('01612f'), sts=0x00)
h = self._handler(remote_format='expanded')
# must NOT raise
self.assertIsNone(h.message_received_handler(_FakePdu(bad)))
self.assertIsNone(h.response)
def test_undecodable_por_after_good_response(self):
"""real app response followed by undecodable PoR:
- good response is saved
- tool does not crash."""
registry = bytes(range(120))
app = self._plaintext_resp_sms(self._expanded_secured(b2h(registry)))
part1, part2 = self._fragment_2(app, ref=0x07, first_len=110)
bad_por = self._plaintext_resp_sms(h2b('deadbeef'), sts=0x00)
h = self._handler()
h.message_received_handler(_FakePdu(part1))
h.message_received_handler(_FakePdu(part2))
self.assertIsNone(h.message_received_handler(_FakePdu(bad_por))) # no crash
res, decoded = h.response
self.assertIsNotNone(decoded)
self.assertEqual(decoded.last_response_data, b2h(registry))
def test_single_part_response_still_works(self):
"""small response that fits one SMS turns into self.response, handled as before"""
h = self._handler()
sms = self._plaintext_resp_sms(self._expanded_secured('abcd', sw='9000'))
self.assertLessEqual(len(sms), 140)
h.message_received_handler(_FakePdu(sms))
res, decoded = h.response
self.assertIsNotNone(decoded)
self.assertEqual(decoded.last_response_data, 'abcd')
self.assertEqual(decoded.last_status_word, '9000')
if __name__ == '__main__':
unittest.main()
+123
View File
@@ -103,3 +103,126 @@ class Test_DELIVER(unittest.TestCase):
self.assertEqual(d.tp_pid, 0x7f) self.assertEqual(d.tp_pid, 0x7f)
self.assertEqual(d.tp_dcs, 0xf6) self.assertEqual(d.tp_dcs, 0xf6)
self.assertEqual(d.tp_udl, 8) self.assertEqual(d.tp_udl, 8)
class Test_ConcatenatedSmsReassembler(unittest.TestCase):
"""3GPP TS 23.040 9.2.3.24 reassembly of multi-part SMS.
An OTA response that exceeds a single SHORT MESSAGE is delivered in several parts using
the SEND SHORT MESSAGE proactive command. The receiver must recombine the individual
parts into a single part before decoding."""
OTA_IE = {'iei': 0x71, 'length': 0, 'value': b''}
@staticmethod
def _concat8(ref, tot, seq):
return {'iei': 0x00, 'length': 3, 'value': bytes([ref, tot, seq])}
@staticmethod
def _concat16(ref, tot, seq):
return {'iei': 0x08, 'length': 4, 'value': ref.to_bytes(2, 'big') + bytes([tot, seq])}
@staticmethod
def _part(ies, frag):
return UserDataHeader(ies).to_bytes() + frag
def test_ground_truth_udh(self):
# part 1 UDH observed from sja5: 07 00 03 01 02 01 71 00
built = self._part([self._concat8(1, 2, 1), self.OTA_IE], b'')
self.assertEqual(b2h(built), '0700030102017100')
def test_ground_truth_udh_16bit(self):
# 9.2.3.24.8: 08 | 08 04 <ref16> <total> <seq> | 71 00
built = self._part([self._concat16(0x1234, 2, 1), self.OTA_IE], b'')
self.assertEqual(b2h(built), '080804123402017100')
def test_single_part_passthrough(self):
r = ConcatenatedSmsReassembler()
single = h2b('027100') + bytes(range(20))
self.assertEqual(r.add(single), single)
def test_two_part(self):
# second segment contains only the concat IE, no OTA IE
pkt = bytes(range(60))
r = ConcatenatedSmsReassembler()
self.assertIsNone(r.add(self._part([self._concat8(1, 2, 1), self.OTA_IE], pkt[:35])))
out = r.add(self._part([self._concat8(1, 2, 2)], pkt[35:]))
self.assertEqual(out, h2b('027100') + pkt)
def test_out_of_order(self):
pkt = bytes(range(60))
r = ConcatenatedSmsReassembler()
self.assertIsNone(r.add(self._part([self._concat8(5, 2, 2), self.OTA_IE], pkt[35:])))
out = r.add(self._part([self._concat8(5, 2, 1), self.OTA_IE], pkt[:35]))
self.assertEqual(out, h2b('027100') + pkt)
def test_three_part_out_of_order(self):
pkt = bytes(range(90))
r = ConcatenatedSmsReassembler()
self.assertIsNone(r.add(self._part([self._concat8(7, 3, 3)], pkt[60:])))
self.assertIsNone(r.add(self._part([self._concat8(7, 3, 1), self.OTA_IE], pkt[:30])))
out = r.add(self._part([self._concat8(7, 3, 2)], pkt[30:60]))
self.assertEqual(out, h2b('027100') + pkt)
def test_16bit_reference(self):
pkt = bytes(range(40))
r = ConcatenatedSmsReassembler()
self.assertIsNone(r.add(self._part([self._concat16(0x1234, 2, 1), self.OTA_IE], pkt[:20])))
out = r.add(self._part([self._concat16(0x1234, 2, 2)], pkt[20:]))
self.assertEqual(out, h2b('027100') + pkt)
def test_interleaved_references(self):
# two concurrent concatenation sets at the same time
pkt = bytes(range(60))
r = ConcatenatedSmsReassembler()
self.assertIsNone(r.add(self._part([self._concat8(1, 2, 1), self.OTA_IE], pkt[:35])))
self.assertIsNone(r.add(self._part([self._concat8(9, 2, 1), self.OTA_IE], b'\xaa')))
self.assertEqual(r.add(self._part([self._concat8(1, 2, 2)], pkt[35:])), h2b('027100') + pkt)
self.assertEqual(r.add(self._part([self._concat8(9, 2, 2)], b'\xbb')), h2b('027100') + b'\xaa\xbb')
def test_reserved_concat_ie_is_ignored(self):
# TS 23.040 9.2.3.24.1:
# - a total of 0
# - or a sequence number that is 0 or > total
# means "the receiving entity shall ignore the whole Information Element"
# the message is handed back unchanged as a single part msg and not rejected
# so the caller can handle the problem
r = ConcatenatedSmsReassembler()
for tot, seq in [(2, 3), # seq > total
(2, 0), # seq == 0
(0, 1)]: # total == 0
with self.subTest(total=tot, seq=seq):
part = self._part([self._concat8(1, tot, seq)], b'\x00')
self.assertEqual(r.add(part), part)
# nothing buffered so later valid set still reassembles properly
self.assertEqual(r.sets, {})
pkt = bytes(range(40))
self.assertIsNone(r.add(self._part([self._concat8(1, 2, 1), self.OTA_IE], pkt[:20])))
self.assertEqual(r.add(self._part([self._concat8(1, 2, 2)], pkt[20:])), h2b('027100') + pkt)
def test_inconsistent_totals_do_not_crash(self):
r = ConcatenatedSmsReassembler()
self.assertIsNone(r.add(self._part([self._concat8(1, 3, 3)], b'\x33')))
self.assertIsNone(r.add(self._part([self._concat8(1, 2, 1)], b'\x11')))
self.assertEqual(r.add(self._part([self._concat8(1, 2, 2)], b'\x22')),
h2b('00') + b'\x11\x22') # complete total=2 set
self.assertIn((0x00, 1, 3), r.sets) # total=3 set still waits
def test_incomplete_sets_are_capped(self):
r = ConcatenatedSmsReassembler(max_sets=2)
for ref in (1, 2, 3):
self.assertIsNone(r.add(self._part([self._concat8(ref, 2, 1)], bytes([ref]))))
self.assertEqual(sorted(k[1] for k in r.sets), [2, 3]) # oldest evicted
self.assertIsNone(r.add(self._part([self._concat8(1, 2, 2)], b'\x11')))
self.assertEqual(sorted(k[1] for k in r.sets), [1, 3])
self.assertEqual(r.add(self._part([self._concat8(3, 2, 2)], b'\x33')), h2b('00') + b'\x03\x33')
def test_same_reference_in_both_ie_forms(self):
# the refno only unique per IE form (9.2.3.24.1 vs .8) -> two sets
r = ConcatenatedSmsReassembler()
self.assertIsNone(r.add(self._part([self._concat8(1, 2, 1)], b'\x0a')))
self.assertIsNone(r.add(self._part([self._concat16(1, 2, 2)], b'\x1b')))
self.assertEqual(r.add(self._part([self._concat16(1, 2, 1)], b'\x0b')),
h2b('00') + b'\x0b\x1b')
self.assertEqual(r.add(self._part([self._concat8(1, 2, 2)], b'\x1a')),
h2b('00') + b'\x0a\x1a')
+1
View File
@@ -21,6 +21,7 @@ import logging
from osmocom.utils import b2h, h2b, all_subclasses from osmocom.utils import b2h, h2b, all_subclasses
from osmocom.tlv import * from osmocom.tlv import *
import pySim.cat
import pySim.iso7816_4 import pySim.iso7816_4
import pySim.ts_102_221 import pySim.ts_102_221
import pySim.ts_102_222 import pySim.ts_102_222
+264
View File
@@ -0,0 +1,264 @@
#!/usr/bin/env python3
"""Transport (as in t0/t1) tests"""
# (C) 2026 by sysmocom - s.f.m.c. GmbH
# All Rights Reserved
#
# Author: Eric Wild <ewild@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/>.
import unittest
from osmocom.utils import h2b, b2h
from pySim.cat import ProactiveCommand, CommandDetails, DeviceIdentities, Result
from pySim.transport import ProactiveHandler, LinkBaseTpdu
def _send_short_message_pcmd():
"""proactive SEND SHORT MESSAGE:
D0 | CommandDetails(cmd 1, t 0x13, q 0) | DeviceIdentities(uicc->network)
| dummy SMS_TPDU"""
body = h2b('8103011300' + '82028183' + '8B04DEADBEEF')
pdu = h2b('D0') + bytes([len(body)]) + body
pcmd = ProactiveCommand()
decoded = pcmd.from_tlv(pdu)
return pcmd, decoded
class Test_prepare_response(unittest.TestCase):
"""TERMINAL RESPONSE.
multi-part OTA response crash regression test."""
def setUp(self):
self.h = ProactiveHandler.__new__(ProactiveHandler)
def test_on_decoded_command(self):
_pcmd, decoded = _send_short_message_pcmd()
til = self.h.prepare_response(decoded)
self.assertEqual([type(c).__name__ for c in til],
['CommandDetails', 'DeviceIdentities', 'Result'])
# command details echoed, device id inverted, result OK
self.assertEqual(b2h(til[0].to_tlv()), '8103011300')
self.assertEqual(b2h(til[1].to_tlv()), '82028381')
self.assertEqual(b2h(til[2].to_tlv()), '830100')
def test_on_collection_resolves_via_decoded(self):
# Check that ProactiveCommand collection (empty .children) still works
pcmd, _decoded = _send_short_message_pcmd()
self.assertEqual(list(getattr(pcmd, 'children', []) or []), [])
til = self.h.prepare_response(pcmd)
self.assertEqual([type(c).__name__ for c in til],
['CommandDetails', 'DeviceIdentities', 'Result'])
self.assertEqual(b2h(til[0].to_tlv()), '8103011300')
self.assertEqual(b2h(til[1].to_tlv()), '82028381')
self.assertEqual(b2h(til[2].to_tlv()), '830100')
def test_missing_command_details_raises_clear_error(self):
class _NoChildren:
children = []
with self.assertRaises(ValueError) as ctx:
self.h.prepare_response(_NoChildren())
self.assertIn('CommandDetails', str(ctx.exception))
class FakeTpduLink(LinkBaseTpdu):
"""mock LinkBaseTpdu that replays a list of (data, sw) responses + records every TPDU that
the T=0 state machine sends. Secretly sending more TPDUs than intended is the error,
designed to test "unsolicited GET RESPONSE" mishaps"""
def __init__(self, responses):
super().__init__()
self._responses = list(responses)
self.sent = []
def send_tpdu(self, tpdu):
self.sent.append(tpdu.lower())
if not self._responses:
raise AssertionError('T=0 layer sent an unpexpected TPDU: %s (total so far: %s)'
% (tpdu, self.sent))
return self._responses.pop(0)
def __str__(self):
return 'FakeTpduLink'
def wait_for_card(self, timeout=None, newcardonly=False):
pass
def connect(self):
pass
def get_atr(self):
return '3b00'
def disconnect(self):
pass
def _reset_card(self):
pass
# GP GET STATUS, wrapped in SCP02 CLA 84, Case #4.
GET_STATUS = '84f22002094f005c054f9f70c5cc' + '00'
GET_STATUS_TPDU = '84f22002094f005c054f9f70c5cc'
# generic #4 SELECT by DF name command
CASE4 = '00a4040c07a0000000871002' + '00'
CASE4_TPDU = '00a4040c07a0000000871002'
class Test_send_apdu_T0(unittest.TestCase):
"""regression tests for the T=0 state machine in LinkBaseTpdu.__send_apdu_T0()"""
def _exchange(self, apdu, responses, strict=True, protocol=0):
link = FakeTpduLink(responses)
link.apdu_strict = strict
link.set_tpdu_format(protocol)
data, sw = link._send_apdu(apdu)
return link, data, sw
#### TS 102 221 section 7.3.1.1 TPDU construction
def test_case1_gets_le_appended(self):
link, data, sw = self._exchange('00200001', [('', '9000')])
self.assertEqual(link.sent, ['0020000100'])
self.assertEqual((data, sw), ('', '9000'))
def test_case3_passed_through_unmodified(self):
apdu = '00200001081122334455667788'
link, _data, sw = self._exchange(apdu, [('', '9000')])
self.assertEqual(link.sent, [apdu])
self.assertEqual(sw, '9000')
def test_case4_le_stripped(self):
link, data, sw = self._exchange(CASE4, [('', '9000')])
self.assertEqual(link.sent, [CASE4_TPDU])
self.assertEqual((data, sw), ('', '9000'))
#### TS 102 221 7.3.1.1.4 4a GP GET RESPONSE for 61xx / 9fxx
def test_61xx_fetches_response(self):
link, data, sw = self._exchange(CASE4, [('', '6103'), ('a1b2c3', '9000')])
self.assertEqual(link.sent, [CASE4_TPDU, '00c0000003'])
self.assertEqual((data, sw), ('a1b2c3', '9000'))
def test_61xx_chained(self):
link, data, sw = self._exchange(CASE4,
[('', '6102'), ('aabb', '6102'), ('ccdd', '9000')])
self.assertEqual(link.sent, [CASE4_TPDU, '00c0000002', '00c0000002'])
self.assertEqual((data, sw), ('aabbccdd', '9000'))
def test_9fxx_fetches_response(self):
link, data, sw = self._exchange(CASE4, [('', '9f04'), ('deadbeef', '9000')])
self.assertEqual(link.sent, [CASE4_TPDU, '00c0000004'])
self.assertEqual((data, sw), ('deadbeef', '9000'))
def test_get_response_inherits_cla(self):
"""GET RESPONSE must reuse CLA of command"""
link, _data, _sw = self._exchange(GET_STATUS, [('', '6102'), ('aabb', '9000')])
self.assertEqual(link.sent, [GET_STATUS_TPDU, '84c0000002'])
def test_9100_terminates(self):
"""9100 is final status word, not fetch trigger"""
link, data, sw = self._exchange(CASE4, [('', '9100')])
self.assertEqual(link.sent, [CASE4_TPDU])
self.assertEqual((data, sw), ('', '9100'))
def test_error_sw_terminates(self):
link, data, sw = self._exchange(CASE4, [('', '6982')])
self.assertEqual(link.sent, [CASE4_TPDU])
self.assertEqual((data, sw), ('', '6982'))
def test_no_status_word_raises(self):
with self.assertRaises(ValueError):
self._exchange(CASE4, [('', None)])
#### TS 102 221 7.3.1.1.4 4b dummy GET RESPONSE
def test_clause_4b_warning_before_data_bootstraps(self):
"""warning SW returned for the _command_ TPDU triggers dummy GET RESPONSE (Le=00)"""
for warn in ('6200', '6281', '62f1', '6300', '63f1'):
with self.subTest(sw=warn):
link, data, sw = self._exchange(CASE4,
[('', warn), ('', '6103'), ('a1b2c3', '9000')])
self.assertEqual(link.sent, [CASE4_TPDU, '00c0000000', '00c0000003'])
self.assertEqual((data, sw), ('a1b2c3', '9000'))
def test_warning_after_data_terminates(self):
"""Once the response has been fetched a warning status word is the final result of the command"""
for warn in ('6281', '6283', '63c2', '6300', '62f1', '63f1', '6310'):
with self.subTest(sw=warn):
link, data, sw = self._exchange(CASE4, [('', '6102'), ('aabb', warn)])
self.assertEqual(link.sent, [CASE4_TPDU, '00c0000002'])
self.assertEqual((data, sw), ('aabb', warn))
def test_no_dummy_get_response_when_command_already_returned_data(self):
"""warning that arrives together with response data (for example 6282 on a case #2 read) is final, too"""
link, data, sw = self._exchange('00b0000004', [('01020304', '6282')], strict=False)
self.assertEqual(link.sent, ['00b0000004'])
self.assertEqual((data, sw), ('01020304', '6282'))
def test_repeated_warning_does_not_loop(self):
"""warning -> dummy GET RESPONSE -> warning again must terminate"""
link, data, sw = self._exchange(CASE4, [('', '6281'), ('', '6281')])
self.assertEqual(link.sent, [CASE4_TPDU, '00c0000000'])
self.assertEqual((data, sw), ('', '6281'))
#### fixed GlobalPlatform GET STATUS pagination
def test_gp_6310_reaches_the_caller(self):
"""GET STATUS answers 6310"""
link, data, sw = self._exchange(GET_STATUS, [('', '6104'), ('e3024f00', '6310')])
self.assertEqual(link.sent, [GET_STATUS_TPDU, '84c0000004'])
self.assertEqual((data, sw), ('e3024f00', '6310'))
def test_gp_get_status_two_pages(self):
"""Both GET STATUS pages, page 1 6310, reissued with P2 bit 1 set, page 2 9000."""
page1 = self._exchange(GET_STATUS, [('', '6104'), ('e3024f00', '6310')])
self.assertEqual(page1[1:], ('e3024f00', '6310'))
page2 = self._exchange('84f22003094f005c054f9f70c5cc00',
[('', '6104'), ('e3024f01', '9000')])
self.assertEqual(page2[0].sent, ['84f22003094f005c054f9f70c5cc', '84c0000004'])
self.assertEqual(page2[1:], ('e3024f01', '9000'))
#### 6cxx and apdu_strict
def test_6cxx_reissues_command_with_correct_length(self):
link, data, sw = self._exchange('00b0000000', [('', '6c04'), ('01020304', '9000')])
self.assertEqual(link.sent, ['00b0000000', '00b0000004'])
self.assertEqual((data, sw), ('01020304', '9000'))
def test_strict_mode_does_not_auto_fetch_for_case3(self):
apdu = '00200001081122334455667788'
link, data, sw = self._exchange(apdu, [('', '6104')], strict=True)
self.assertEqual(link.sent, [apdu])
self.assertEqual((data, sw), ('', '6104'))
def test_non_strict_mode_auto_fetches_for_case3(self):
apdu = '00200001081122334455667788'
link, data, sw = self._exchange(apdu, [('', '6104'), ('aabbccdd', '9000')], strict=False)
self.assertEqual(link.sent, [apdu, '00c0000004'])
self.assertEqual((data, sw), ('aabbccdd', '9000'))
#### T=1 briefly
def test_t1_is_passed_through(self):
"""T=1 has no GET RESPONSE"""
link, data, sw = self._exchange(GET_STATUS, [('e3024f00', '6310')], protocol=1)
self.assertEqual(link.sent, [GET_STATUS.lower()])
self.assertEqual((data, sw), ('e3024f00', '6310'))
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
+9
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']