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