package device import ( "crypto/rand" "encoding/hex" "errors" "fmt" "strconv" "strings" "time" "unicode/utf16" ) var gsm7DefaultAlphabet = [128]rune{ '@', '£', '$', '¥', 'è', 'é', 'ù', 'ì', 'ò', 'Ç', '\n', 'Ø', 'ø', '\r', 'Å', 'å', 'Δ', '_', 'Φ', 'Γ', 'Λ', 'Ω', 'Π', 'Ψ', 'Σ', 'Θ', 'Ξ', '\x1b', 'Æ', 'æ', 'ß', 'É', ' ', '!', '"', '#', '¤', '%', '&', '\'', '(', ')', '*', '+', ',', '-', '.', '/', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ':', ';', '<', '=', '>', '?', '¡', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'Ä', 'Ö', 'Ñ', 'Ü', '§', '¿', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'ä', 'ö', 'ñ', 'ü', 'à', } var gsm7ExtensionAlphabet = map[byte]rune{ 0x0a: '\f', 0x14: '^', 0x28: '{', 0x29: '}', 0x2f: '\\', 0x3c: '[', 0x3d: '~', 0x3e: ']', 0x40: '|', 0x65: '€', } var gsm7Encoder = func() map[rune][]byte { result := make(map[rune][]byte, len(gsm7DefaultAlphabet)+len(gsm7ExtensionAlphabet)) for code, character := range gsm7DefaultAlphabet { if code == 0x1b { continue } result[character] = []byte{byte(code)} } for code, character := range gsm7ExtensionAlphabet { result[character] = []byte{0x1b, code} } return result }() type preparedSMS struct { to string encoding SMSEncoding payload []byte prompt string setup []string tpduLength int part int total int concatReference *int } // PrepareSMSSubmitTPDUs applies the same recipient validation, alphabet // selection and concatenation rules as SendSMS, but returns raw SMS-SUBMIT // TPDUs suitable for a non-AT transport such as 3GPP SMS over IMS. func PrepareSMSSubmitTPDUs(recipient, text string) ([]SMSSubmitTPDU, error) { parts, err := prepareSMSParts(recipient, text) if err != nil { return nil, err } result := make([]SMSSubmitTPDU, 0, len(parts)) for _, part := range parts { pduHex := string(part.payload) encoding := part.encoding if part.encoding == SMSEncodingGSM7Text { septets, ok := encodeGSM7(text) if !ok { return nil, errors.New("SMS text could not be encoded as GSM-7") } pduHex, _, err = encodeSubmitPDU(part.to, septets, nil) if err != nil { return nil, err } encoding = SMSEncodingGSM7PDU } pdu, err := hex.DecodeString(pduHex) if err != nil || len(pdu) < 2 { return nil, errors.New("encoded SMS PDU is invalid") } smscBytes := int(pdu[0]) if 1+smscBytes >= len(pdu) { return nil, errors.New("encoded SMS PDU has no TPDU") } result = append(result, SMSSubmitTPDU{ To: part.to, Encoding: encoding, TPDU: append([]byte(nil), pdu[1+smscBytes:]...), Part: part.part, Total: part.total, ConcatReference: part.concatReference, }) } return result, nil } // DecodeSMSDeliverTPDU decodes a TPDU carried by RP-DATA. The existing modem // decoder expects the AT PDU-mode SMSC prefix, so an empty SMSC is prepended. func DecodeSMSDeliverTPDU(tpdu []byte) (SMSMessage, error) { if len(tpdu) == 0 { return SMSMessage{}, errors.New("SMS TPDU is empty") } pdu := make([]byte, 1, len(tpdu)+1) pdu = append(pdu, tpdu...) return decodeSMSPDU(hex.EncodeToString(pdu)) } func prepareSMS(recipient, text string) (preparedSMS, error) { parts, err := prepareSMSPartsWithReference(recipient, text, 0) if err != nil { return preparedSMS{}, err } if len(parts) != 1 { return preparedSMS{}, fmt.Errorf( "%w: message requires %d parts", ErrSMSTooLong, len(parts), ) } return parts[0], nil } func prepareSMSParts(recipient, text string) ([]preparedSMS, error) { parts, err := prepareSMSPartsWithReference(recipient, text, 0) if err != nil || len(parts) <= 1 { return parts, err } var reference [1]byte if _, err := rand.Read(reference[:]); err != nil { return nil, fmt.Errorf("generate SMS concatenation reference: %w", err) } return prepareSMSPartsWithReference(recipient, text, reference[0]) } func prepareSMSPartsWithReference( recipient string, text string, reference byte, ) ([]preparedSMS, error) { recipient, err := normalizeSMSRecipient(recipient) if err != nil { return nil, err } if text == "" { return nil, ErrSMSEmpty } if strings.IndexByte(text, 0) >= 0 { return nil, errors.New("SMS text contains NUL") } septets, gsm7 := encodeGSM7(text) if gsm7 && len(septets) <= 160 && canSendDirectGSM7(text) { return []preparedSMS{{ to: recipient, encoding: SMSEncodingGSM7Text, payload: []byte(text), prompt: fmt.Sprintf(`AT+CMGS="%s"`, recipient), setup: []string{ "AT+CMGF=1", `AT+CSCS="GSM"`, // TP-SRR requests a network delivery status report. "AT+CSMP=49,167,0,0", }, part: 1, total: 1, }}, nil } if gsm7 && len(septets) <= 160 { pdu, tpduLength, err := encodeSubmitPDU(recipient, septets, nil) if err != nil { return nil, err } return []preparedSMS{{ to: recipient, encoding: SMSEncodingGSM7PDU, payload: []byte(pdu), prompt: fmt.Sprintf("AT+CMGS=%d", tpduLength), setup: []string{"AT+CMGF=0"}, tpduLength: tpduLength, part: 1, total: 1, }}, nil } if gsm7 { segments := splitGSM7(septets, 153) if len(segments) > 255 { return nil, fmt.Errorf( "%w: GSM-7 requires %d concatenated parts; maximum is 255", ErrSMSTooLong, len(segments), ) } referenceValue := int(reference) result := make([]preparedSMS, 0, len(segments)) for index, segment := range segments { header := concatUDH(reference, len(segments), index+1) pdu, tpduLength, encodeErr := encodeSubmitPDUWithHeader( recipient, segment, nil, header, ) if encodeErr != nil { return nil, encodeErr } result = append(result, preparedSMS{ to: recipient, encoding: SMSEncodingGSM7PDU, payload: []byte(pdu), prompt: fmt.Sprintf("AT+CMGS=%d", tpduLength), setup: []string{"AT+CMGF=0"}, tpduLength: tpduLength, part: index + 1, total: len(segments), concatReference: &referenceValue, }) } return result, nil } units := utf16.Encode([]rune(text)) if len(units) <= 70 { ucs2 := encodeUCS2Units(units) pdu, tpduLength, err := encodeSubmitPDU(recipient, nil, ucs2) if err != nil { return nil, err } return []preparedSMS{{ to: recipient, encoding: SMSEncodingUCS2PDU, payload: []byte(pdu), prompt: fmt.Sprintf("AT+CMGS=%d", tpduLength), setup: []string{"AT+CMGF=0"}, tpduLength: tpduLength, part: 1, total: 1, }}, nil } segments := splitUCS2(units, 67) if len(segments) > 255 { return nil, fmt.Errorf( "%w: UCS2 requires %d concatenated parts; maximum is 255", ErrSMSTooLong, len(segments), ) } referenceValue := int(reference) result := make([]preparedSMS, 0, len(segments)) for index, segment := range segments { header := concatUDH(reference, len(segments), index+1) pdu, tpduLength, encodeErr := encodeSubmitPDUWithHeader( recipient, nil, encodeUCS2Units(segment), header, ) if encodeErr != nil { return nil, encodeErr } result = append(result, preparedSMS{ to: recipient, encoding: SMSEncodingUCS2PDU, payload: []byte(pdu), prompt: fmt.Sprintf("AT+CMGS=%d", tpduLength), setup: []string{"AT+CMGF=0"}, tpduLength: tpduLength, part: index + 1, total: len(segments), concatReference: &referenceValue, }) } return result, nil } func splitGSM7(septets []byte, maximum int) [][]byte { var result [][]byte for len(septets) > 0 { count := maximum if count > len(septets) { count = len(septets) } if count < len(septets) && septets[count-1] == 0x1b { count-- } result = append(result, append([]byte(nil), septets[:count]...)) septets = septets[count:] } return result } func splitUCS2(units []uint16, maximum int) [][]uint16 { var result [][]uint16 for len(units) > 0 { count := maximum if count > len(units) { count = len(units) } if count < len(units) && utf16.IsSurrogate(rune(units[count-1])) && units[count-1] >= 0xd800 && units[count-1] <= 0xdbff && units[count] >= 0xdc00 && units[count] <= 0xdfff { count-- } result = append(result, append([]uint16(nil), units[:count]...)) units = units[count:] } return result } func encodeUCS2Units(units []uint16) []byte { result := make([]byte, 0, len(units)*2) for _, unit := range units { result = append(result, byte(unit>>8), byte(unit)) } return result } func concatUDH(reference byte, total, sequence int) []byte { return []byte{0x05, 0x00, 0x03, reference, byte(total), byte(sequence)} } func normalizeSMSRecipient(value string) (string, error) { var result strings.Builder for _, character := range strings.TrimSpace(value) { switch { case character >= '0' && character <= '9': result.WriteRune(character) case character == '+' && result.Len() == 0: result.WriteRune(character) case character == ' ' || character == '-' || character == '(' || character == ')': default: return "", ErrSMSInvalidRecipient } } normalized := result.String() digits := strings.TrimPrefix(normalized, "+") if len(digits) < 1 || len(digits) > 20 { return "", ErrSMSInvalidRecipient } if strings.IndexFunc(digits, func(character rune) bool { return character < '0' || character > '9' }) >= 0 { return "", ErrSMSInvalidRecipient } return normalized, nil } func encodeGSM7(text string) ([]byte, bool) { result := make([]byte, 0, len(text)) for _, character := range text { encoded, ok := gsm7Encoder[character] if !ok { return nil, false } result = append(result, encoded...) } return result, true } func canSendDirectGSM7(text string) bool { for _, character := range text { encoded, ok := gsm7Encoder[character] if !ok || len(encoded) != 1 || encoded[0] == 0x1b || character > 0x7f || byte(character) != encoded[0] { return false } } return true } func encodeSubmitPDU( recipient string, gsm7Septets []byte, ucs2 []byte, ) (string, int, error) { return encodeSubmitPDUWithHeader(recipient, gsm7Septets, ucs2, nil) } func encodeSubmitPDUWithHeader( recipient string, gsm7Septets []byte, ucs2 []byte, userDataHeader []byte, ) (string, int, error) { digits := strings.TrimPrefix(recipient, "+") address, err := encodeSemiOctets(digits) if err != nil { return "", 0, err } toa := byte(0x81) if strings.HasPrefix(recipient, "+") { toa = 0x91 } // SMS-SUBMIT with TP-SRR set so both AT PDU mode and SMS over IMS can // receive an SMS-STATUS-REPORT for this submission. firstOctet := byte(0x21) if len(userDataHeader) > 0 { if int(userDataHeader[0])+1 != len(userDataHeader) { return "", 0, errors.New("invalid SMS concatenation header") } firstOctet |= 0x40 } pdu := []byte{ 0x00, // Use the SMSC configured on the SIM. firstOctet, 0x00, // TP-MR allocated by the modem/network. byte(len(digits)), toa, } pdu = append(pdu, address...) pdu = append(pdu, 0x00) // TP-PID switch { case gsm7Septets != nil: headerSeptets := 0 startBit := 0 if len(userDataHeader) > 0 { headerSeptets = (len(userDataHeader)*8 + 6) / 7 startBit = headerSeptets * 7 } userDataLength := headerSeptets + len(gsm7Septets) if userDataLength > 160 { return "", 0, ErrSMSTooLong } packed := packSeptets(gsm7Septets, startBit) copy(packed, userDataHeader) pdu = append(pdu, 0x00, byte(userDataLength)) pdu = append(pdu, packed...) case ucs2 != nil: userDataLength := len(userDataHeader) + len(ucs2) if userDataLength > 140 { return "", 0, ErrSMSTooLong } pdu = append(pdu, 0x08, byte(userDataLength)) pdu = append(pdu, userDataHeader...) pdu = append(pdu, ucs2...) default: return "", 0, errors.New("SMS PDU has no user data") } return strings.ToUpper(hex.EncodeToString(pdu)), len(pdu) - 1, nil } func encodeSemiOctets(digits string) ([]byte, error) { if digits == "" { return nil, ErrSMSInvalidRecipient } result := make([]byte, (len(digits)+1)/2) for index := 0; index < len(digits); index += 2 { low := digits[index] if low < '0' || low > '9' { return nil, ErrSMSInvalidRecipient } high := byte('F') if index+1 < len(digits) { high = digits[index+1] if high < '0' || high > '9' { return nil, ErrSMSInvalidRecipient } } highNibble := byte(0x0f) if high != 'F' { highNibble = high - '0' } result[index/2] = (highNibble << 4) | (low - '0') } return result, nil } func packSeptets(septets []byte, startBit int) []byte { if len(septets) == 0 { return nil } bitLength := startBit + len(septets)*7 result := make([]byte, (bitLength+7)/8) for index, septet := range septets { bit := startBit + index*7 byteIndex := bit / 8 shift := uint(bit % 8) result[byteIndex] |= (septet & 0x7f) << shift if shift > 1 && byteIndex+1 < len(result) { result[byteIndex+1] |= (septet & 0x7f) >> (8 - shift) } } return result } func unpackSeptets(data []byte, count, startBit int) ([]byte, error) { if count < 0 || startBit < 0 || startBit+count*7 > len(data)*8 { return nil, errors.New("GSM-7 user data is truncated") } result := make([]byte, count) for index := 0; index < count; index++ { bit := startBit + index*7 byteIndex := bit / 8 shift := uint(bit % 8) value := data[byteIndex] >> shift if shift > 1 && byteIndex+1 < len(data) { value |= data[byteIndex+1] << (8 - shift) } result[index] = value & 0x7f } return result, nil } func decodeGSM7(septets []byte) (string, error) { var result strings.Builder for index := 0; index < len(septets); index++ { code := septets[index] if code != 0x1b { if int(code) >= len(gsm7DefaultAlphabet) { return result.String(), errors.New("invalid GSM-7 code") } result.WriteRune(gsm7DefaultAlphabet[code]) continue } index++ if index >= len(septets) { return result.String(), errors.New("trailing GSM-7 escape") } character, ok := gsm7ExtensionAlphabet[septets[index]] if !ok { return result.String(), fmt.Errorf( "unknown GSM-7 extension 0x%02X", septets[index], ) } result.WriteRune(character) } return result.String(), nil } type pduCursor struct { data []byte index int } func (cursor *pduCursor) byte() (byte, error) { if cursor.index >= len(cursor.data) { return 0, errors.New("SMS PDU is truncated") } value := cursor.data[cursor.index] cursor.index++ return value, nil } func (cursor *pduCursor) bytes(count int) ([]byte, error) { if count < 0 || cursor.index+count > len(cursor.data) { return nil, errors.New("SMS PDU is truncated") } value := cursor.data[cursor.index : cursor.index+count] cursor.index += count return value, nil } func decodeSMSPDU(raw string) (SMSMessage, error) { raw = strings.ToUpper(strings.TrimSpace(raw)) message := SMSMessage{ Direction: SMSDirectionUnknown, Encoding: SMSEncodingUnknown, StorageStatus: SMSStatusUnknown, RawPDU: raw, } decoded, err := hex.DecodeString(raw) if err != nil || len(decoded) < 2 { if err == nil { err = errors.New("SMS PDU is too short") } return message, err } cursor := &pduCursor{data: decoded} smscLength, _ := cursor.byte() if int(smscLength) > len(decoded)-1 { return message, errors.New("SMS PDU SMSC length exceeds payload") } if smscLength > 0 { smsc, _ := cursor.bytes(int(smscLength)) if len(smsc) > 1 { message.ServiceCenter = decodeNumericAddress( smsc[1:], (len(smsc)-1)*2, smsc[0], ) } } firstOctet, err := cursor.byte() if err != nil { return message, err } switch firstOctet & 0x03 { case 0: message.Direction = SMSDirectionReceived err = decodeDeliverPDU(cursor, firstOctet, &message) case 1: message.Direction = SMSDirectionSubmitted err = decodeSubmitPDU(cursor, firstOctet, &message) case 2: message.Direction = SMSDirectionStatusReport err = decodeStatusReportPDU(cursor, &message) default: err = errors.New("unsupported SMS PDU message type") } return message, err } func decodeDeliverPDU( cursor *pduCursor, firstOctet byte, message *SMSMessage, ) error { address, err := readTPAddress(cursor) if err != nil { return err } message.From = address pid, err := cursor.byte() if err != nil { return err } dcs, err := cursor.byte() if err != nil { return err } message.ProtocolID = int(pid) message.DataCodingScheme = int(dcs) timestamp, err := cursor.bytes(7) if err != nil { return err } if parsed, timestampErr := decodeSMSTimestamp(timestamp); timestampErr == nil { message.ServiceCenterTimestamp = parsed } udl, err := cursor.byte() if err != nil { return err } return decodeUserData(cursor.data[cursor.index:], firstOctet, dcs, int(udl), message) } func decodeSubmitPDU( cursor *pduCursor, firstOctet byte, message *SMSMessage, ) error { reference, err := cursor.byte() if err != nil { return err } message.MessageReference = intPointer(int(reference)) address, err := readTPAddress(cursor) if err != nil { return err } message.To = address pid, err := cursor.byte() if err != nil { return err } dcs, err := cursor.byte() if err != nil { return err } message.ProtocolID = int(pid) message.DataCodingScheme = int(dcs) switch (firstOctet >> 3) & 0x03 { case 2: if _, err := cursor.byte(); err != nil { return err } case 1, 3: if _, err := cursor.bytes(7); err != nil { return err } } udl, err := cursor.byte() if err != nil { return err } return decodeUserData(cursor.data[cursor.index:], firstOctet, dcs, int(udl), message) } func decodeStatusReportPDU( cursor *pduCursor, message *SMSMessage, ) error { reference, err := cursor.byte() if err != nil { return err } message.MessageReference = intPointer(int(reference)) address, err := readTPAddress(cursor) if err != nil { return err } message.To = address scts, err := cursor.bytes(7) if err != nil { return err } discharge, err := cursor.bytes(7) if err != nil { return err } status, err := cursor.byte() if err != nil { return err } message.StatusCode = intPointer(int(status)) message.DeliveryStatus = smsDeliveryStatus(status) if parsed, parseErr := decodeSMSTimestamp(scts); parseErr == nil { message.ServiceCenterTimestamp = parsed } if parsed, parseErr := decodeSMSTimestamp(discharge); parseErr == nil { message.DischargeTimestamp = parsed } return nil } func smsDeliveryStatus(status byte) string { switch { case status <= 0x1f: return "delivered" case status <= 0x3f: return "temporary_error" case status <= 0x5f: return "permanent_error" case status <= 0x7f: return "temporary_error_no_retry" default: return "reserved" } } func readTPAddress(cursor *pduCursor) (string, error) { length, err := cursor.byte() if err != nil { return "", err } toa, err := cursor.byte() if err != nil { return "", err } byteCount := (int(length) + 1) / 2 value, err := cursor.bytes(byteCount) if err != nil { return "", err } if toa&0x70 == 0x50 { septetCount := int(length) * 4 / 7 septets, unpackErr := unpackSeptets(value, septetCount, 0) if unpackErr != nil { return "", unpackErr } return decodeGSM7(septets) } return decodeNumericAddress(value, int(length), toa), nil } func decodeNumericAddress(value []byte, digits int, toa byte) string { var result strings.Builder if toa&0x70 == 0x10 { result.WriteByte('+') } written := 0 for _, octet := range value { for _, digit := range []byte{octet & 0x0f, octet >> 4} { if written >= digits { return result.String() } if digit <= 9 { result.WriteByte('0' + digit) written++ } } } return result.String() } func decodeUserData( data []byte, firstOctet byte, dcs byte, udl int, message *SMSMessage, ) error { alphabet := dcs & 0x0c expectedBytes := udl if alphabet == 0 { expectedBytes = (udl*7 + 7) / 8 } if expectedBytes > len(data) { return errors.New("SMS user data is truncated") } data = data[:expectedBytes] message.RawUserData = strings.ToUpper(hex.EncodeToString(data)) headerBytes := 0 if firstOctet&0x40 != 0 { if len(data) == 0 { return errors.New("SMS has UDHI but no user data header") } headerBytes = int(data[0]) + 1 if headerBytes > len(data) { return errors.New("SMS user data header is truncated") } message.Concat = parseConcatHeader(data[1:headerBytes]) } switch alphabet { case 0: message.Encoding = SMSEncodingGSM7PDU headerSeptets := 0 if headerBytes > 0 { headerSeptets = (headerBytes*8 + 6) / 7 } textSeptets := udl - headerSeptets septets, err := unpackSeptets(data, textSeptets, headerSeptets*7) if err != nil { return err } text, err := decodeGSM7(septets) message.Text = text return err case 8: message.Encoding = SMSEncodingUCS2PDU payload := data[headerBytes:] if len(payload)%2 != 0 { return errors.New("UCS2 SMS has an odd byte count") } units := make([]uint16, 0, len(payload)/2) for index := 0; index < len(payload); index += 2 { units = append(units, uint16(payload[index])<<8|uint16(payload[index+1])) } message.Text = string(utf16.Decode(units)) return nil default: message.Encoding = SMSEncoding8BitPDU return nil } } func parseConcatHeader(header []byte) *SMSConcatInfo { for index := 0; index+1 < len(header); { identifier := header[index] length := int(header[index+1]) index += 2 if index+length > len(header) { return nil } value := header[index : index+length] switch { case identifier == 0x00 && length == 3: return &SMSConcatInfo{ Reference: int(value[0]), Total: int(value[1]), Sequence: int(value[2]), } case identifier == 0x08 && length == 4: return &SMSConcatInfo{ Reference: int(value[0])<<8 | int(value[1]), Total: int(value[2]), Sequence: int(value[3]), } } index += length } return nil } func decodeSMSTimestamp(value []byte) (*time.Time, error) { if len(value) != 7 { return nil, errors.New("SMS timestamp must be seven octets") } component := func(octet byte) (int, error) { low, high := int(octet&0x0f), int(octet>>4) if low > 9 || high > 9 { return 0, errors.New("invalid timestamp semi-octet") } return low*10 + high, nil } parts := make([]int, 6) for index := range parts { parsed, err := component(value[index]) if err != nil { return nil, err } parts[index] = parsed } zoneByte := value[6] negative := zoneByte&0x08 != 0 zoneByte &^= 0x08 quarters, err := component(zoneByte) if err != nil || quarters > 56 { return nil, errors.New("invalid SMS timestamp timezone") } offset := quarters * 15 * 60 if negative { offset = -offset } year := 2000 + parts[0] if parts[0] >= 90 { year = 1900 + parts[0] } if parts[1] < 1 || parts[1] > 12 || parts[2] < 1 || parts[2] > 31 || parts[3] > 23 || parts[4] > 59 || parts[5] > 60 { return nil, errors.New("invalid SMS timestamp component") } zone := time.FixedZone("SMS", offset) result := time.Date( year, time.Month(parts[1]), parts[2], parts[3], parts[4], parts[5], 0, zone, ) return &result, nil } func parseDecimal(value string) (int, bool) { number, err := strconv.Atoi(strings.Trim(strings.TrimSpace(value), `"`)) return number, err == nil }