forked from public/pysim
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
This commit is contained in:
@@ -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()
|
||||
@@ -103,3 +103,126 @@ class Test_DELIVER(unittest.TestCase):
|
||||
self.assertEqual(d.tp_pid, 0x7f)
|
||||
self.assertEqual(d.tp_dcs, 0xf6)
|
||||
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')
|
||||
|
||||
Reference in New Issue
Block a user