"""Code related to SMS Encoding/Decoding""" # simplistic SMS T-PDU code, as unfortunately nobody bothered to port the python smspdu # module to python3, and I gave up after >= 3 hours of trying and failing to do so # (C) 2022 by Harald Welte # # 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 . 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 from construct import Prefixed, GreedyRange from osmocom.construct import BcdAdapter, TonNpi, Bytes, GreedyBytes 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: # a single IE in the user data header ie_c = Struct('iei'/Int8ub, 'length'/Int8ub, 'value'/Bytes(this.length)) # parser for the full UDH: Length octet followed by sequence of IEs _construct = Struct('ies'/Prefixed(Int8ub, GreedyRange(ie_c)), 'data'/GreedyBytes) def __init__(self, ies=[]): self.ies = ies def __repr__(self) -> str: return 'UDH(%r)' % self.ies def has_ie(self, iei:int) -> bool: for ie in self.ies: if ie['iei'] == iei: return True return False @classmethod def from_bytes(cls, inb: BytesOrHex) -> typing.Tuple['UserDataHeader', bytes]: if isinstance(inb, str): inb = h2b(inb) res = cls._construct.parse(inb) return cls(res['ies']), res['data'] def to_bytes(self) -> bytes: 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, pdu_types.DataCodingDefault.OCTET_UNSPECIFIED): return True if dcs == pdu_types.DataCoding(pdu_types.DataCodingScheme.DEFAULT, pdu_types.DataCodingDefault.OCTET_UNSPECIFIED_COMMON): return True # pySim/sms.py:72:21: E1101: Instance of 'DataCodingScheme' has no 'GSM_MESSAGE_CLASS' member (no-member) # pylint: disable=no-member if dcs.scheme == pdu_types.DataCodingScheme.GSM_MESSAGE_CLASS and dcs.schemeData['msgCoding'] == pdu_types.DataCodingGsmMsgCoding.DATA_8BIT: return True else: return False def ensure_smpp_is_8bit(dcs: pdu_types.DataCoding): """Assert if given SMPP data coding scheme is not 8-bit.""" if not smpp_dcs_is_8bit(dcs): raise ValueError('We only support 8bit coded SMS for now') class AddressField: """Representation of an address field as used in SMS T-PDU.""" _construct = Struct('addr_len'/Int8ub, 'type_of_addr'/TonNpi, 'digits'/BcdAdapter(Bytes(this.addr_len//2 + this.addr_len%2)), 'tell'/Tell) smpp_map_npi = bidict({ 'UNKNOWN': 'unknown', 'ISDN': 'isdn_e164', 'DATA': 'data_x121', 'TELEX': 'telex_f69', 'LAND_MOBILE': 'sc_specific6', 'NATIONAL': 'national', 'PRIVATE': 'private', 'ERMES': 'ermes', }) smpp_map_ton = bidict({ 'UNKNOWN': 'unknown', 'INTERNATIONAL': 'international', 'NATIONAL': 'national', 'NETWORK_SPECIFIC': 'network_specific', 'SUBSCRIBER_NUMBER': 'short_code', 'ALPHANUMERIC': 'alphanumeric', 'ABBREVIATED': 'abbreviated', }) def __init__(self, digits, ton='unknown', npi='unknown'): self.ton = ton self.npi = npi self.digits = digits def __str__(self): return 'AddressField(TON=%s, NPI=%s, %s)' % (self.ton, self.npi, self.digits) @classmethod def from_bytes(cls, inb: BytesOrHex) -> typing.Tuple['AddressField', bytes]: """Construct an AddressField instance from the binary T-PDU address format.""" if isinstance(inb, str): inb = h2b(inb) res = cls._construct.parse(inb) #print("size: %s" % cls._construct.sizeof()) ton = res['type_of_addr']['type_of_number'] npi = res['type_of_addr']['numbering_plan_id'] # return resulting instance + remainder bytes return cls(res['digits'][:res['addr_len']], ton, npi), inb[res['tell']:] @classmethod def from_smpp(cls, addr, ton, npi) -> 'AddressField': """Construct an AddressField from {source,dest}_addr_{,ton,npi} attributes of smpp.pdu.""" # return the resulting instance return cls(addr.decode('ascii'), AddressField.smpp_map_ton[ton.name], AddressField.smpp_map_npi[npi.name]) def to_smpp(self): """Return smpp.pdo.*.source,dest}_addr_{,ton,npi} attributes for given AddressField.""" return (self.digits, self.smpp_map_ton.inverse[self.ton], self.smpp_map_npi.inverse[self.npi]) def to_bytes(self) -> bytes: """Encode the AddressField into the binary representation as used in T-PDU.""" num_digits = len(self.digits) # don't store the filler nibble or get_bytes() encodes it as digit and ends up too large digits = self.digits + 'f' if num_digits % 2 else self.digits d = { 'addr_len': num_digits, 'type_of_addr': { 'ext': True, 'type_of_number': self.ton, 'numbering_plan_id': self.npi, }, 'digits': digits, } return self._construct.build(d) class SMS_TPDU(abc.ABC): """Base class for a SMS T-PDU.""" def __init__(self, **kwargs): self.tp_mti = kwargs.get('tp_mti', None) self.tp_rp = kwargs.get('tp_rp', False) self.tp_udhi = kwargs.get('tp_udhi', False) self.tp_pid = kwargs.get('tp_pid', None) self.tp_dcs = kwargs.get('tp_dcs', None) self.tp_udl = kwargs.get('tp_udl', None) self.tp_ud = kwargs.get('tp_ud', None) class SMS_DELIVER(SMS_TPDU): """Representation of a SMS-DELIVER T-PDU. This is the Network to MS/UE (downlink) direction.""" flags_construct = BitStruct('tp_rp'/Flag, 'tp_udhi'/Flag, 'tp_sri'/Flag, Padding(1), 'tp_lp'/Flag, 'tp_mms'/Flag, 'tp_mti'/BitsInteger(2)) def __init__(self, **kwargs): kwargs['tp_mti'] = 0 super().__init__(**kwargs) self.tp_lp = kwargs.get('tp_lp', False) self.tp_mms = kwargs.get('tp_mms', False) self.tp_oa = kwargs.get('tp_oa', None) self.tp_scts = kwargs.get('tp_scts', None) self.tp_sri = kwargs.get('tp_sri', False) def __repr__(self): return '%s(MTI=%s, MMS=%s, LP=%s, RP=%s, UDHI=%s, SRI=%s, OA=%s, PID=%2x, DCS=%x, SCTS=%s, UDL=%u, UD=%s)' % (self.__class__.__name__, self.tp_mti, self.tp_mms, self.tp_lp, self.tp_rp, self.tp_udhi, self.tp_sri, self.tp_oa, self.tp_pid, self.tp_dcs, self.tp_scts, self.tp_udl, self.tp_ud) @classmethod def from_bytes(cls, inb: BytesOrHex) -> 'SMS_DELIVER': """Construct a SMS_DELIVER instance from the binary encoded format as used in T-PDU.""" if isinstance(inb, str): inb = h2b(inb) d = SMS_DELIVER.flags_construct.parse(inb) oa, remainder = AddressField.from_bytes(inb[1:]) d['tp_oa'] = oa offset = 0 d['tp_pid'] = remainder[offset] offset += 1 d['tp_dcs'] = remainder[offset] offset += 1 # TODO: further decode d['tp_scts'] = remainder[offset:offset+7] offset += 7 d['tp_udl'] = remainder[offset] offset += 1 d['tp_ud'] = remainder[offset:] return cls(**d) def to_bytes(self) -> bytes: """Encode a SMS_DELIVER instance to the binary encoded format as used in T-PDU.""" outb = bytearray() d = { 'tp_mti': self.tp_mti, 'tp_mms': self.tp_mms, 'tp_lp': self.tp_lp, 'tp_rp': self.tp_rp, 'tp_udhi': self.tp_udhi, 'tp_sri': self.tp_sri, } flags = SMS_DELIVER.flags_construct.build(d) outb.extend(flags) outb.extend(self.tp_oa.to_bytes()) outb.append(self.tp_pid) outb.append(self.tp_dcs) outb.extend(self.tp_scts) outb.append(self.tp_udl) outb.extend(self.tp_ud) return outb @classmethod def from_smpp(cls, smpp_pdu) -> 'SMS_DELIVER': """Construct a SMS_DELIVER instance from the deliver format used by smpp.pdu.""" if smpp_pdu.id == pdu_types.CommandId.submit_sm: return cls.from_smpp_submit(smpp_pdu) else: raise ValueError('Unsupported SMPP commandId %s' % smpp_pdu.id) @classmethod def from_smpp_submit(cls, smpp_pdu) -> 'SMS_DELIVER': """Construct a SMS_DELIVER instance from the submit format used by smpp.pdu.""" ensure_smpp_is_8bit(smpp_pdu.params['data_coding']) tp_oa = AddressField.from_smpp(smpp_pdu.params['source_addr'], smpp_pdu.params['source_addr_ton'], smpp_pdu.params['source_addr_npi']) tp_ud = smpp_pdu.params['short_message'] d = { 'tp_lp': False, 'tp_mms': False, 'tp_oa': tp_oa, 'tp_scts': h2b('22705200000000'), # FIXME 'tp_sri': False, 'tp_rp': False, 'tp_udhi': pdu_types.EsmClassGsmFeatures.UDHI_INDICATOR_SET in smpp_pdu.params['esm_class'].gsmFeatures, 'tp_pid': smpp_pdu.params['protocol_id'], 'tp_dcs': 0xF6, # we only deal with binary SMS here 'tp_udl': len(tp_ud), 'tp_ud': tp_ud, } return cls(**d) @classmethod def from_submit(cls, submit: 'SMS_SUBMIT') -> 'SMS_DELIVER': """Construct a SMS_DELIVER instance from a SMS_SUBMIT instance.""" d = { # common fields (SMS_TPDU base class) which exist in submit, so we can copy them 'tp_mti': submit.tp_mti, 'tp_rp': submit.tp_rp, 'tp_udhi': submit.tp_udhi, 'tp_pid': submit.tp_pid, 'tp_dcs': submit.tp_dcs, 'tp_udl': submit.tp_udl, 'tp_ud': submit.tp_ud, # SMS_DELIVER specific fields 'tp_lp': False, 'tp_mms': False, 'tp_oa': None, 'tp_scts': h2b('22705200000000'), # FIXME 'tp_sri': False, } return cls(**d) def to_smpp(self) -> pdu_types.PDU: """Translate a SMS_DELIVER instance to a smpp.pdu.operations.DeliverSM instance.""" # we only deal with binary SMS here: if self.tp_dcs != 0xF6: raise ValueError('Unsupported DCS: We only support DCS=0xF6 for now') dcs = pdu_types.DataCoding(pdu_types.DataCodingScheme.DEFAULT, pdu_types.DataCodingDefault.OCTET_UNSPECIFIED) esm_class = pdu_types.EsmClass(pdu_types.EsmClassMode.DEFAULT, pdu_types.EsmClassType.DEFAULT, gsmFeatures=[pdu_types.EsmClassGsmFeatures.UDHI_INDICATOR_SET]) if self.tp_oa: oa_digits, oa_ton, oa_npi = self.tp_oa.to_smpp() else: oa_digits, oa_ton, oa_npi = None, None, None return operations.DeliverSM(source_addr=oa_digits, source_addr_ton=oa_ton, source_addr_npi=oa_npi, #destination_addr=ESME_MSISDN, esm_class=esm_class, protocol_id=self.tp_pid, data_coding=dcs, short_message=self.tp_ud) class SMS_SUBMIT(SMS_TPDU): """Representation of a SMS-SUBMIT T-PDU. This is the MS/UE -> network (uplink) direction.""" flags_construct = BitStruct('tp_srr'/Flag, 'tp_udhi'/Flag, 'tp_rp'/Flag, 'tp_vpf'/Enum(BitsInteger(2), none=0, relative=2, enhanced=1, absolute=3), 'tp_rd'/Flag, 'tp_mti'/BitsInteger(2)) def __init__(self, **kwargs): kwargs['tp_mti'] = 1 super().__init__(**kwargs) self.tp_rd = kwargs.get('tp_rd', False) self.tp_vpf = kwargs.get('tp_vpf', 'none') self.tp_srr = kwargs.get('tp_srr', False) self.tp_mr = kwargs.get('tp_mr', None) self.tp_da = kwargs.get('tp_da', None) self.tp_vp = kwargs.get('tp_vp', None) def __repr__(self): return '%s(MTI=%s, RD=%s, VPF=%u, RP=%s, UDHI=%s, SRR=%s, DA=%s, PID=%2x, DCS=%x, VP=%s, UDL=%u, UD=%s)' % (self.__class__.__name__, self.tp_mti, self.tp_rd, self.tp_vpf, self.tp_rp, self.tp_udhi, self.tp_srr, self.tp_da, self.tp_pid, self.tp_dcs, self.tp_vp, self.tp_udl, self.tp_ud) @classmethod def from_bytes(cls, inb:BytesOrHex) -> 'SMS_SUBMIT': """Construct a SMS_SUBMIT instance from the binary encoded format as used in T-PDU.""" offset = 0 if isinstance(inb, str): inb = h2b(inb) d = SMS_SUBMIT.flags_construct.parse(inb) offset += 1 d['tp_mr']= inb[offset] offset += 1 da, remainder = AddressField.from_bytes(inb[2:]) d['tp_da'] = da offset = 0 d['tp_pid'] = remainder[offset] offset += 1 d['tp_dcs'] = remainder[offset] offset += 1 if d['tp_vpf'] == 'none': pass elif d['tp_vpf'] == 'relative': # TODO: further decode d['tp_vp'] = remainder[offset:offset+1] offset += 1 elif d['tp_vpf'] == 'enhanced': # TODO: further decode d['tp_vp'] = remainder[offset:offset+7] offset += 7 elif d['tp_vpf'] == 'absolute': # TODO: further decode d['tp_vp'] = remainder[offset:offset+7] offset += 7 else: raise ValueError('Invalid VPF: %s' % d['tp_vpf']) d['tp_udl'] = remainder[offset] offset += 1 d['tp_ud'] = remainder[offset:] return cls(**d) def to_bytes(self) -> bytes: """Encode a SMS_SUBMIT instance to the binary encoded format as used in T-PDU.""" outb = bytearray() d = { 'tp_mti': self.tp_mti, 'tp_rd': self.tp_rd, 'tp_vpf': self.tp_vpf, 'tp_rp': self.tp_rp, 'tp_udhi': self.tp_udhi, 'tp_srr': self.tp_srr, } flags = SMS_SUBMIT.flags_construct.build(d) outb.extend(flags) outb.append(self.tp_mr) outb.extend(self.tp_da.to_bytes()) outb.append(self.tp_pid) outb.append(self.tp_dcs) if self.tp_vpf != 'none': outb.extend(self.tp_vp) outb.append(self.tp_udl) outb.extend(self.tp_ud) return outb @classmethod def from_smpp(cls, smpp_pdu) -> 'SMS_SUBMIT': """Construct a SMS_SUBMIT instance from the format used by smpp.pdu.""" if smpp_pdu.id == pdu_types.CommandId.submit_sm: return cls.from_smpp_submit(smpp_pdu) else: raise ValueError('Unsupported SMPP commandId %s' % smpp_pdu.id) @classmethod def from_smpp_submit(cls, smpp_pdu) -> 'SMS_SUBMIT': """Construct a SMS_SUBMIT instance from the submit format used by smpp.pdu.""" ensure_smpp_is_8bit(smpp_pdu.params['data_coding']) tp_da = AddressField.from_smpp(smpp_pdu.params['destination_addr'], smpp_pdu.params['dest_addr_ton'], smpp_pdu.params['dest_addr_npi']) tp_ud = smpp_pdu.params['short_message'] #vp_smpp = smpp_pdu.params['validity_period'] #if not vp_smpp: # vpf = 'none' d = { 'tp_rd': True if smpp_pdu.params['replace_if_present_flag'].name == 'REPLACE' else False, 'tp_vpf': None, # vpf, 'tp_rp': False, # related to ['registered_delivery'] ? 'tp_udhi': pdu_types.EsmClassGsmFeatures.UDHI_INDICATOR_SET in smpp_pdu.params['esm_class'].gsmFeatures, 'tp_srr': True if smpp_pdu.params['registered_delivery'] else False, 'tp_mr': 0, # FIXME: sm_default_msg_id ? 'tp_da': tp_da, 'tp_pid': smpp_pdu.params['protocol_id'], 'tp_dcs': 0xF6, # FIXME: we only deal with binary SMS here 'tp_vp': None, # FIXME: implement VPF conversion 'tp_udl': len(tp_ud), 'tp_ud': tp_ud, } return cls(**d) def to_smpp(self) -> pdu_types.PDU: """Translate a SMS_SUBMIT instance to a smpp.pdu.operations.SubmitSM instance.""" esm_class = pdu_types.EsmClass(pdu_types.EsmClassMode.DEFAULT, pdu_types.EsmClassType.DEFAULT) reg_del = pdu_types.RegisteredDelivery(pdu_types.RegisteredDeliveryReceipt.NO_SMSC_DELIVERY_RECEIPT_REQUESTED) if self.tp_rp: repl_if = pdu_types.ReplaceIfPresentFlag.REPLACE else: repl_if = pdu_types.ReplaceIfPresentFlag.DO_NOT_REPLACE # we only deal with binary SMS here: if self.tp_dcs != 0xF6: raise ValueError('Unsupported DCS: We only support DCS=0xF6 for now') dc = pdu_types.DataCoding(pdu_types.DataCodingScheme.DEFAULT, pdu_types.DataCodingDefault.OCTET_UNSPECIFIED) (daddr, ton, npi) = self.tp_da.to_smpp() return operations.SubmitSM(service_type='', source_addr_ton=pdu_types.AddrTon.ALPHANUMERIC, source_addr_npi=pdu_types.AddrNpi.UNKNOWN, source_addr='simcard', dest_addr_ton=ton, dest_addr_npi=npi, destination_addr=daddr, esm_class=esm_class, protocol_id=self.tp_pid, priority_flag=pdu_types.PriorityFlag.LEVEL_0, #schedule_delivery_time, #validity_period, registered_delivery=reg_del, replace_if_present_flag=repl_if, data_coding=dc, #sm_default_msg_id, short_message=self.tp_ud)