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:
+46
-16
@@ -24,7 +24,8 @@ import smpplib.gsm
|
||||
import smpplib.client
|
||||
import smpplib.consts
|
||||
import time
|
||||
from pySim.ota import OtaKeyset, OtaDialectSms, OtaAlgoCrypt, OtaAlgoAuth, CNTR_REQ, RC_CC_DS, POR_REQ
|
||||
from pySim.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
|
||||
|
||||
@@ -108,28 +109,57 @@ class SmppHandler:
|
||||
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 pdu.short_message:
|
||||
logger.info("SMS-TPDU received: %s", b2h(pdu.short_message))
|
||||
try:
|
||||
dec = self.ota_dialect.decode_resp(self.ota_keyset, self.spi, pdu.short_message,
|
||||
remote_format=self.remote_format)
|
||||
except ValueError:
|
||||
# Retry to decoding with ciphering disabled (in case the card has problems to decode the SMS-TDPU
|
||||
# we have sent, the response will contain an unencrypted error message)
|
||||
spi = self.spi.copy()
|
||||
spi['por_shall_be_ciphered'] = False
|
||||
spi['por_rc_cc_ds'] = 'no_rc_cc_ds'
|
||||
dec = self.ota_dialect.decode_resp(self.ota_keyset, spi, pdu.short_message,
|
||||
remote_format=self.remote_format)
|
||||
logger.info("SMS-TPDU decoded: %s", dec)
|
||||
self.response = dec
|
||||
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):
|
||||
|
||||
+106
@@ -19,6 +19,7 @@
|
||||
|
||||
import typing
|
||||
import abc
|
||||
import logging
|
||||
from bidict import bidict
|
||||
from construct import Int8ub, Byte, Bit, Flag, BitsInteger
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BytesOrHex = typing.Union[Hexstr, bytes]
|
||||
|
||||
class UserDataHeader:
|
||||
@@ -60,6 +63,109 @@ class UserDataHeader:
|
||||
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:
|
||||
"""Determine if the given SMPP data coding scheme is 8-bit or not."""
|
||||
if dcs == pdu_types.DataCoding(pdu_types.DataCodingScheme.DEFAULT,
|
||||
|
||||
@@ -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