mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-21 07:13:43 +08:00
@@ -5,10 +5,12 @@ go 1.25.0
|
||||
require (
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/iniwex5/quectel-qmi-go v0.6.0
|
||||
github.com/warthog618/sms v0.3.0
|
||||
go.bug.st/serial v1.6.4
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/text v0.41.0
|
||||
howett.net/plist v1.0.1
|
||||
modernc.org/sqlite v1.38.2
|
||||
)
|
||||
@@ -21,7 +23,6 @@ require (
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/stretchr/testify v1.10.0 // indirect
|
||||
github.com/warthog618/sms v0.3.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
modernc.org/libc v1.66.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
@@ -46,6 +46,8 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
+247
-25
@@ -8,7 +8,13 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf16"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/warthog618/sms/encoding/gsm7"
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
"golang.org/x/text/transform"
|
||||
)
|
||||
|
||||
var gsm7DefaultAlphabet = [128]rune{
|
||||
@@ -770,14 +776,19 @@ func readTPAddress(cursor *pduCursor) (string, error) {
|
||||
var septetCount int
|
||||
if toa&0x70 == 0x50 {
|
||||
// 3GPP TS 23.040 §9.1.2.5: For alphanumeric addresses, the length field
|
||||
// indicates the number of useful semi-octets (i.e. characters * 7 / 4, rounded up).
|
||||
// The number of characters is (length * 4) / 7 and byte count is (length + 1) / 2.
|
||||
// However, some non-standard sources specify length as the direct count of septets
|
||||
// (e.g. length=4 for 4 chars, which needs 4 bytes instead of (4+1)/2=2 bytes).
|
||||
if length >= 7 {
|
||||
byteCount = (int(length) + 1) / 2
|
||||
septetCount = int(length) * 4 / 7
|
||||
} else {
|
||||
// is a count of useful semi-octets, not a character count. In particular,
|
||||
// a three-character sender such as "OKX" has length 6. Treating every
|
||||
// short length as a septet count consumes PID/DCS bytes as part of the
|
||||
// address and shifts the entire TPDU, producing plausible-looking GSM-7
|
||||
// garbage instead of the message body.
|
||||
byteCount = (int(length) + 1) / 2
|
||||
septetCount = int(length) * 4 / 7
|
||||
|
||||
// A few legacy/non-standard sources do put the character count in this
|
||||
// field. Retain compatibility only when the standard-sized value cannot
|
||||
// be a valid zero-padded GSM-7 address; do not guess based on its length.
|
||||
if byteCount == 0 || cursor.index+byteCount > len(cursor.data) ||
|
||||
!hasZeroGSM7Padding(cursor.data[cursor.index:cursor.index+byteCount], septetCount) {
|
||||
byteCount = (int(length)*7 + 7) / 8
|
||||
septetCount = int(length)
|
||||
}
|
||||
@@ -798,6 +809,18 @@ func readTPAddress(cursor *pduCursor) (string, error) {
|
||||
return decodeNumericAddress(value, int(length), toa), nil
|
||||
}
|
||||
|
||||
func hasZeroGSM7Padding(data []byte, septetCount int) bool {
|
||||
if septetCount <= 0 || septetCount*7 > len(data)*8 {
|
||||
return false
|
||||
}
|
||||
for bit := septetCount * 7; bit < len(data)*8; bit++ {
|
||||
if data[bit/8]&(byte(1)<<uint(bit%8)) != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func decodeNumericAddress(value []byte, digits int, toa byte) string {
|
||||
var result strings.Builder
|
||||
if toa&0x70 == 0x10 {
|
||||
@@ -825,9 +848,9 @@ func decodeUserData(
|
||||
udl int,
|
||||
message *SMSMessage,
|
||||
) error {
|
||||
alphabet := dcs & 0x0c
|
||||
alphabet := decodeSMSAlphabet(dcs)
|
||||
expectedBytes := udl
|
||||
if alphabet == 0 {
|
||||
if alphabet == smsAlphabetGSM7 {
|
||||
expectedBytes = (udl*7 + 7) / 8
|
||||
}
|
||||
if expectedBytes > len(data) {
|
||||
@@ -848,8 +871,12 @@ func decodeUserData(
|
||||
message.Concat = parseConcatHeader(data[1:headerBytes])
|
||||
}
|
||||
|
||||
var header []byte
|
||||
if headerBytes > 0 {
|
||||
header = data[1:headerBytes]
|
||||
}
|
||||
switch alphabet {
|
||||
case 0:
|
||||
case smsAlphabetGSM7:
|
||||
message.Encoding = SMSEncodingGSM7PDU
|
||||
headerSeptets := 0
|
||||
if headerBytes > 0 {
|
||||
@@ -860,33 +887,228 @@ func decodeUserData(
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
text, err := decodeGSM7(septets)
|
||||
text, err := decodeGSM7WithHeader(septets, header)
|
||||
message.Text = text
|
||||
return err
|
||||
case 8:
|
||||
case smsAlphabetUCS2:
|
||||
message.Encoding = SMSEncodingUCS2PDU
|
||||
payload := data[headerBytes:]
|
||||
if len(payload)%2 != 0 {
|
||||
return errors.New("UCS2 SMS has an odd byte count")
|
||||
text, ok := decodeUTF16Bytes(payload)
|
||||
if ok {
|
||||
message.Text = text
|
||||
return nil
|
||||
}
|
||||
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]))
|
||||
// Some gateways label UTF-8 or a local 8-bit character set as UCS-2.
|
||||
// Only accept a fallback when it is unambiguously readable text.
|
||||
if text, encoding, detected := decodeTextBytes(payload, header); detected {
|
||||
message.Text = text
|
||||
message.Encoding = encoding
|
||||
return nil
|
||||
}
|
||||
message.Text = string(utf16.Decode(units))
|
||||
return nil
|
||||
return errors.New("UCS2 SMS has invalid UTF-16 data")
|
||||
default:
|
||||
// 8-bit (binary) user data has no portable text representation, so the
|
||||
// raw payload bytes are rendered as uppercase hexadecimal after the user
|
||||
// data header is stripped. This keeps the bubble non-empty and gives a
|
||||
// faithful rendering of the delivered content rather than a blank "".
|
||||
message.Encoding = SMSEncoding8BitPDU
|
||||
payload := data[headerBytes:]
|
||||
if text, encoding, detected := decodeTextBytes(payload, header); detected {
|
||||
message.Text = text
|
||||
message.Encoding = encoding
|
||||
return nil
|
||||
}
|
||||
// Port-addressed or non-text 8-bit data remains hexadecimal, preserving
|
||||
// binary SMS (WAP push, provisioning, SIM data) without lossy guessing.
|
||||
message.Encoding = SMSEncoding8BitPDU
|
||||
message.Text = strings.ToUpper(hex.EncodeToString(payload))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type smsAlphabet byte
|
||||
|
||||
const (
|
||||
smsAlphabetGSM7 smsAlphabet = iota
|
||||
smsAlphabet8Bit
|
||||
smsAlphabetUCS2
|
||||
smsAlphabetUnknown
|
||||
)
|
||||
|
||||
// decodeSMSAlphabet applies the complete 3GPP TS 23.038 DCS grouping rules.
|
||||
// A plain dcs&0x0c check is incorrect for message-waiting groups Cx/Dx/Ex and
|
||||
// reserved coding groups, and can silently select the wrong decoder.
|
||||
func decodeSMSAlphabet(dcs byte) smsAlphabet {
|
||||
switch {
|
||||
case dcs&0x80 == 0:
|
||||
if dcs&0x20 != 0 { // GSM compression is not safely decodable here.
|
||||
return smsAlphabetUnknown
|
||||
}
|
||||
switch (dcs >> 2) & 0x03 {
|
||||
case 0:
|
||||
return smsAlphabetGSM7
|
||||
case 1:
|
||||
return smsAlphabet8Bit
|
||||
case 2:
|
||||
return smsAlphabetUCS2
|
||||
default:
|
||||
return smsAlphabetUnknown
|
||||
}
|
||||
case dcs&0xe0 == 0xc0: // Cx and Dx message-waiting groups use GSM-7.
|
||||
return smsAlphabetGSM7
|
||||
case dcs&0xf0 == 0xe0: // Ex message-waiting group uses UCS-2.
|
||||
return smsAlphabetUCS2
|
||||
case dcs&0xf0 == 0xf0:
|
||||
if dcs&0x04 != 0 {
|
||||
return smsAlphabet8Bit
|
||||
}
|
||||
return smsAlphabetGSM7
|
||||
default:
|
||||
return smsAlphabetUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func decodeGSM7WithHeader(septets, header []byte) (string, error) {
|
||||
locking, hasLocking := userDataHeaderLanguage(header, 0x25)
|
||||
shift, hasShift := userDataHeaderLanguage(header, 0x24)
|
||||
if !hasLocking && !hasShift {
|
||||
return decodeGSM7(septets)
|
||||
}
|
||||
options := make([]gsm7.DecoderOption, 0, 2)
|
||||
if hasLocking {
|
||||
options = append(options, gsm7.WithCharset(locking))
|
||||
}
|
||||
if hasShift {
|
||||
options = append(options, gsm7.WithExtCharset(shift))
|
||||
}
|
||||
decoded, err := gsm7.Decode(septets, options...)
|
||||
return string(decoded), err
|
||||
}
|
||||
|
||||
func userDataHeaderLanguage(header []byte, identifier byte) (int, bool) {
|
||||
for index := 0; index+1 < len(header); {
|
||||
id := header[index]
|
||||
length := int(header[index+1])
|
||||
index += 2
|
||||
if index+length > len(header) {
|
||||
return 0, false
|
||||
}
|
||||
if id == identifier && length == 1 {
|
||||
return int(header[index]), true
|
||||
}
|
||||
index += length
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func decodeUTF16Bytes(payload []byte) (string, bool) {
|
||||
if len(payload) == 0 {
|
||||
return "", true
|
||||
}
|
||||
if len(payload)%2 != 0 {
|
||||
return "", false
|
||||
}
|
||||
littleEndian := len(payload) >= 2 && payload[0] == 0xff && payload[1] == 0xfe
|
||||
if (payload[0] == 0xfe && payload[1] == 0xff) || littleEndian {
|
||||
payload = payload[2:]
|
||||
}
|
||||
units := make([]uint16, 0, len(payload)/2)
|
||||
for index := 0; index < len(payload); index += 2 {
|
||||
unit := uint16(payload[index])<<8 | uint16(payload[index+1])
|
||||
if littleEndian {
|
||||
unit = uint16(payload[index+1])<<8 | uint16(payload[index])
|
||||
}
|
||||
units = append(units, unit)
|
||||
}
|
||||
text := string(utf16.Decode(units))
|
||||
return text, !strings.ContainsRune(text, unicode.ReplacementChar) && readableText(text)
|
||||
}
|
||||
|
||||
func decodeTextBytes(payload, header []byte) (string, SMSEncoding, bool) {
|
||||
if hasApplicationPortAddressing(header) || len(payload) == 0 {
|
||||
return "", SMSEncoding8BitPDU, false
|
||||
}
|
||||
if len(payload) >= 2 && ((payload[0] == 0xfe && payload[1] == 0xff) ||
|
||||
(payload[0] == 0xff && payload[1] == 0xfe)) {
|
||||
if text, ok := decodeUTF16Bytes(payload); ok {
|
||||
return text, SMSEncodingUCS2PDU, true
|
||||
}
|
||||
}
|
||||
if utf8.Valid(payload) {
|
||||
text := string(payload)
|
||||
if readableText(text) {
|
||||
return text, SMSEncodingUTF8PDU, true
|
||||
}
|
||||
}
|
||||
if containsNonASCII(payload) {
|
||||
decoded, _, err := transform.Bytes(simplifiedchinese.GB18030.NewDecoder(), payload)
|
||||
text := string(decoded)
|
||||
if err == nil && strings.ContainsFunc(text, func(character rune) bool {
|
||||
return unicode.Is(unicode.Han, character)
|
||||
}) && readableText(text) {
|
||||
return text, SMSEncodingGB18030, true
|
||||
}
|
||||
}
|
||||
if text, ok := decodeLatin1Text(payload); ok {
|
||||
return text, SMSEncodingLatin1, true
|
||||
}
|
||||
return "", SMSEncoding8BitPDU, false
|
||||
}
|
||||
|
||||
func readableText(text string) bool {
|
||||
if text == "" {
|
||||
return true
|
||||
}
|
||||
printable, total := 0, 0
|
||||
for _, character := range text {
|
||||
total++
|
||||
if unicode.IsPrint(character) || character == '\n' || character == '\r' || character == '\t' {
|
||||
printable++
|
||||
}
|
||||
}
|
||||
return printable*100 >= total*90
|
||||
}
|
||||
|
||||
func containsNonASCII(data []byte) bool {
|
||||
for _, value := range data {
|
||||
if value >= utf8.RuneSelf {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func decodeLatin1Text(payload []byte) (string, bool) {
|
||||
characters := make([]rune, 0, len(payload))
|
||||
ascii := 0
|
||||
for _, value := range payload {
|
||||
switch {
|
||||
case value == '\n' || value == '\r' || value == '\t' || value >= 0x20 && value <= 0x7e:
|
||||
ascii++
|
||||
case value >= 0xa0:
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
characters = append(characters, rune(value))
|
||||
}
|
||||
if ascii == 0 || ascii*2 < len(payload) {
|
||||
return "", false
|
||||
}
|
||||
text := string(characters)
|
||||
return text, readableText(text)
|
||||
}
|
||||
|
||||
func hasApplicationPortAddressing(header []byte) bool {
|
||||
for index := 0; index+1 < len(header); {
|
||||
identifier := header[index]
|
||||
length := int(header[index+1])
|
||||
index += 2
|
||||
if index+length > len(header) {
|
||||
return true
|
||||
}
|
||||
if (identifier == 0x04 && length == 2) || (identifier == 0x05 && length == 4) {
|
||||
return true
|
||||
}
|
||||
index += length
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseConcatHeader(header []byte) *SMSConcatInfo {
|
||||
for index := 0; index+1 < len(header); {
|
||||
identifier := header[index]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -323,6 +324,35 @@ func TestDecodeDeliverPDUWithAlphanumericSender(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeDeliverPDUWithShortStandardAlphanumericSender(t *testing.T) {
|
||||
// TP-OA length is expressed in useful semi-octets. The three-character
|
||||
// sender "OKX" therefore has length 6, even though it contains 3 septets.
|
||||
// A previous short-address heuristic interpreted 6 as the character count
|
||||
// and swallowed PID, DCS, and timestamp bytes into the sender address.
|
||||
text := "Your OKX verification code is: 123456"
|
||||
textSeptets, ok := encodeGSM7(text)
|
||||
if !ok {
|
||||
t.Fatal("test text is not GSM-7 encodable")
|
||||
}
|
||||
pdu := []byte{0x00, 0x04, 0x06, 0xd0}
|
||||
pdu = append(pdu, packSeptets([]byte{'O', 'K', 'X'}, 0)...)
|
||||
pdu = append(pdu,
|
||||
0x00, 0x00, // PID and GSM-7 DCS.
|
||||
0x62, 0x80, 0x20, 0x91, 0x40, 0x95, 0x00, // 2026-08-02 19:04:59 UTC.
|
||||
byte(len(textSeptets)),
|
||||
)
|
||||
pdu = append(pdu, packSeptets(textSeptets, 0)...)
|
||||
|
||||
message, err := decodeSMSPDU(hex.EncodeToString(pdu))
|
||||
if err != nil {
|
||||
t.Fatalf("decode short alphanumeric sender: %v", err)
|
||||
}
|
||||
if message.From != "OKX" || message.Text != text ||
|
||||
message.Encoding != SMSEncodingGSM7PDU || message.DataCodingScheme != 0 {
|
||||
t.Fatalf("message = %#v", message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecode8BitPDUShowsHexPayload(t *testing.T) {
|
||||
// SMS-DELIVER with no SMSC, from +12345, DCS=0xF5 (8-bit data,
|
||||
// alphabet bits 0x0c), UDL=3. User data bytes are 0xAA 0xBB 0xCC.
|
||||
@@ -340,3 +370,87 @@ func TestDecode8BitPDUShowsHexPayload(t *testing.T) {
|
||||
t.Fatalf("8-bit message = %#v", message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeUserDataUnderstandsDCSGroups(t *testing.T) {
|
||||
septets, ok := encodeGSM7("HELLO")
|
||||
if !ok {
|
||||
t.Fatal("encode GSM-7 test text")
|
||||
}
|
||||
packed := packSeptets(septets, 0)
|
||||
for _, dcs := range []byte{0x00, 0xc8, 0xd0, 0xf0} {
|
||||
message := SMSMessage{}
|
||||
if err := decodeUserData(packed, 0, dcs, len(septets), &message); err != nil {
|
||||
t.Fatalf("decode DCS 0x%02X: %v", dcs, err)
|
||||
}
|
||||
if message.Text != "HELLO" || message.Encoding != SMSEncodingGSM7PDU {
|
||||
t.Fatalf("DCS 0x%02X message = %#v", dcs, message)
|
||||
}
|
||||
}
|
||||
|
||||
ucs2 := []byte{0x4f, 0x60, 0x59, 0x7d}
|
||||
for _, dcs := range []byte{0x08, 0xe0} {
|
||||
message := SMSMessage{}
|
||||
if err := decodeUserData(ucs2, 0, dcs, len(ucs2), &message); err != nil {
|
||||
t.Fatalf("decode DCS 0x%02X: %v", dcs, err)
|
||||
}
|
||||
if message.Text != "你好" || message.Encoding != SMSEncodingUCS2PDU {
|
||||
t.Fatalf("DCS 0x%02X message = %#v", dcs, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeGSM7NationalLanguageTables(t *testing.T) {
|
||||
// National language locking shift IEI 0x25, Turkish table 1. In that
|
||||
// locking table septet 0x07 is the dotless i (ı), rather than default ì.
|
||||
header := []byte{0x03, 0x25, 0x01, 0x01}
|
||||
headerSeptets := (len(header)*8 + 6) / 7
|
||||
data := packSeptets([]byte{0x07}, headerSeptets*7)
|
||||
copy(data, header)
|
||||
|
||||
message := SMSMessage{}
|
||||
if err := decodeUserData(data, 0x40, 0x00, headerSeptets+1, &message); err != nil {
|
||||
t.Fatalf("decode Turkish locking table: %v", err)
|
||||
}
|
||||
if message.Text != "ı" || message.Encoding != SMSEncodingGSM7PDU {
|
||||
t.Fatalf("message = %#v", message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecode8BitTextEncodingsAndPreservesBinary(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload []byte
|
||||
wantText string
|
||||
encoding SMSEncoding
|
||||
}{
|
||||
{name: "UTF-8", payload: []byte("验证码 123456"), wantText: "验证码 123456", encoding: SMSEncodingUTF8PDU},
|
||||
{name: "GB18030", payload: []byte{0xd1, 0xe9, 0xd6, 0xa4, 0xc2, 0xeb}, wantText: "验证码", encoding: SMSEncodingGB18030},
|
||||
{name: "Latin-1", payload: []byte{'C', 'a', 'f', 0xe9}, wantText: "Café", encoding: SMSEncodingLatin1},
|
||||
{name: "binary", payload: []byte{0xaa, 0xbb, 0xcc}, wantText: "AABBCC", encoding: SMSEncoding8BitPDU},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
message := SMSMessage{}
|
||||
if err := decodeUserData(test.payload, 0, 0x04, len(test.payload), &message); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if message.Text != test.wantText || message.Encoding != test.encoding {
|
||||
t.Fatalf("message = %#v", message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePortAddressed8BitSMSRemainsBinary(t *testing.T) {
|
||||
header := []byte{0x04, 0x04, 0x02, 0x0b, 0x84}
|
||||
data := append(append([]byte(nil), header...), []byte("plain-looking payload")...)
|
||||
message := SMSMessage{}
|
||||
if err := decodeUserData(data, 0x40, 0x04, len(data), &message); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
payload := data[len(header):]
|
||||
if message.Text != strings.ToUpper(hex.EncodeToString(payload)) ||
|
||||
message.Encoding != SMSEncoding8BitPDU {
|
||||
t.Fatalf("message = %#v", message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,9 @@ const (
|
||||
SMSEncodingGSM7Text SMSEncoding = "gsm7_text"
|
||||
SMSEncodingGSM7PDU SMSEncoding = "gsm7_pdu"
|
||||
SMSEncodingUCS2PDU SMSEncoding = "ucs2_pdu"
|
||||
SMSEncodingUTF8PDU SMSEncoding = "utf8_pdu"
|
||||
SMSEncodingGB18030 SMSEncoding = "gb18030_pdu"
|
||||
SMSEncodingLatin1 SMSEncoding = "latin1_pdu"
|
||||
SMSEncoding8BitPDU SMSEncoding = "8bit_pdu"
|
||||
SMSEncodingUnknown SMSEncoding = "unknown"
|
||||
)
|
||||
|
||||
@@ -662,11 +662,9 @@ func inspectIgnoredCarrierFields(plists []ipccPlist, warnings *ipccWarningSet) {
|
||||
case key == "media" && strings.Contains(strings.ToLower(strings.Join(keyPath, ".")), "imsconfig"):
|
||||
warnings.add("device_media_overrides_ignored", "device-family media and codec overrides require hardware validation and were not imported", fullPath)
|
||||
case key == "countryoforiginationformat":
|
||||
warnings.add(
|
||||
"country_of_origination_format_not_imported",
|
||||
"CountryOfOriginationFormat was not imported because VoCat has no trusted runtime country source; a P-Access-Network-Info value must not be fabricated",
|
||||
fullPath,
|
||||
)
|
||||
// PANI is access/session metadata, not a carrier location constant.
|
||||
// The IMS runtime provides one globally and consistently across
|
||||
// REGISTER, MESSAGE, RP-ACK and dialogs, so no profile field is needed.
|
||||
case strings.Contains(key, "emergency") || strings.Contains(key, "e911"):
|
||||
warnings.add("emergency_settings_ignored", "emergency-service settings are never imported", fullPath)
|
||||
}
|
||||
|
||||
@@ -82,7 +82,6 @@ func TestImportCarrierIPCCConvertsBinaryAndXMLPlistsSafely(t *testing.T) {
|
||||
"entitlement_bypass_ignored",
|
||||
"apn_settings_ignored",
|
||||
"device_media_overrides_ignored",
|
||||
"country_of_origination_format_not_imported",
|
||||
"emergency_settings_ignored",
|
||||
} {
|
||||
if !hasIPCCWarning(result.Warnings, code) {
|
||||
|
||||
@@ -799,16 +799,10 @@ func (session *Session) callOriginatingIdentitiesLocked(profile vowifi.CarrierPr
|
||||
}
|
||||
|
||||
func (session *Session) pAccessNetworkInfo() string {
|
||||
profile := vowifi.ResolveCarrierProfile(session.request.Identity)
|
||||
node := strings.TrimSpace(profile.PANINode)
|
||||
if node == "" {
|
||||
node = "000000000000"
|
||||
if session.paniResolved {
|
||||
return ueProvidedPANI(session.pani)
|
||||
}
|
||||
value := "IEEE-802.11;i-wlan-node-id=" + node
|
||||
if country := strings.ToUpper(strings.TrimSpace(profile.PANICountry)); country != "" {
|
||||
value += ";country=" + country
|
||||
}
|
||||
return value + ";network-provided"
|
||||
return sessionPAccessNetworkInfo(session.instanceID)
|
||||
}
|
||||
|
||||
func (session *Session) callUserAgent() string {
|
||||
|
||||
@@ -229,7 +229,7 @@ func TestOutgoingLocalNumberUsesIMSPhoneContextAndMMTelHeaders(t *testing.T) {
|
||||
"P-Preferred-Identity: <tel:+447700900123>\r\n",
|
||||
"P-Preferred-Service: " + mmtelServiceURN + "\r\n",
|
||||
`Accept-Contact: *;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"` + "\r\n",
|
||||
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;network-provided\r\n",
|
||||
"P-Access-Network-Info: " + sessionPAccessNetworkInfo(session.instanceID) + "\r\n",
|
||||
"User-Agent: VoCat Test\r\n",
|
||||
"Accept: application/sdp\r\n",
|
||||
} {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
@@ -594,6 +595,8 @@ type Session struct {
|
||||
callID string
|
||||
fromTag string
|
||||
instanceID string
|
||||
pani string
|
||||
paniResolved bool
|
||||
cseq uint32
|
||||
auth *authenticationState
|
||||
securityProposal securityProposal
|
||||
@@ -658,6 +661,8 @@ func newSession(
|
||||
callID: callToken + "@" + addressHost(connection.LocalAddr()),
|
||||
fromTag: fromTag,
|
||||
instanceID: "urn:uuid:" + instanceID,
|
||||
pani: resolveSessionPAccessNetworkInfo(request.Identity, "urn:uuid:"+instanceID),
|
||||
paniResolved: true,
|
||||
cseq: 1,
|
||||
refreshContext: refreshContext,
|
||||
refreshCancel: refreshCancel,
|
||||
@@ -997,11 +1002,10 @@ func (session *Session) buildRegister(
|
||||
if value := strings.TrimSpace(registerOptions.PVisitedNetworkID); value != "" {
|
||||
lines = append(lines, `P-Visited-Network-ID: "`+value+`"`)
|
||||
}
|
||||
// PANI carries access/location information and must not be fabricated.
|
||||
// In particular, "network-provided" identifies a value inserted by a
|
||||
// trusted network proxy, not one generated by this UE. Send the header
|
||||
// only when a carrier profile explicitly supplies a reviewed value.
|
||||
if pani := optionalRegisterHeader(registerOptions.PAccessNetworkInfo); pani != "" {
|
||||
// PANI describes this UE's access and is stable for the complete IMS
|
||||
// session. The same UE-provided value is used by REGISTER, MESSAGE,
|
||||
// RP-ACK and dialog requests; it never claims to be network-provided.
|
||||
if pani := session.pAccessNetworkInfo(); pani != "" {
|
||||
lines = append(lines, "P-Access-Network-Info: "+pani)
|
||||
}
|
||||
if value := strings.TrimSpace(registerOptions.CellularNetworkInfo); value != "" {
|
||||
@@ -1045,13 +1049,6 @@ func (session *Session) buildRegister(
|
||||
return []byte(strings.Join(lines, "\r\n")), nil
|
||||
}
|
||||
|
||||
func optionalRegisterHeader(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(*value)
|
||||
}
|
||||
|
||||
func (session *Session) buildContact(contactAddress string, registerOptions vowifi.IMSRegisterOptions) string {
|
||||
base := fmt.Sprintf("<sip:%s@%s;transport=%s>", session.identity.user, contactAddress, session.transport)
|
||||
instanceID := session.instanceID
|
||||
@@ -1079,6 +1076,58 @@ func (session *Session) buildContact(contactAddress string, registerOptions vowi
|
||||
}
|
||||
}
|
||||
|
||||
// sessionPAccessNetworkInfo creates a syntactically valid, locally
|
||||
// administered unicast WLAN node identifier from the already-random SIP
|
||||
// instance ID. It discloses neither a real BSSID nor subscriber identity, but
|
||||
// remains stable for every transaction belonging to this IMS registration.
|
||||
func sessionPAccessNetworkInfo(instanceID string) string {
|
||||
instanceID = strings.TrimSpace(instanceID)
|
||||
if instanceID == "" {
|
||||
return ""
|
||||
}
|
||||
digest := sha256.Sum256([]byte(instanceID))
|
||||
digest[0] = (digest[0] | 0x02) & 0xfe // locally administered, unicast
|
||||
return "IEEE-802.11;i-wlan-node-id=" + hex.EncodeToString(digest[:6])
|
||||
}
|
||||
|
||||
// resolveSessionPAccessNetworkInfo freezes the selected value when the IMS
|
||||
// session is created. This prevents a carrier-profile reload from changing
|
||||
// access identity between REGISTER, SMS MESSAGE and its RP-ACK.
|
||||
func resolveSessionPAccessNetworkInfo(identity vowifi.SIMIdentity, instanceID string) string {
|
||||
profile := vowifi.ResolveCarrierProfile(identity)
|
||||
if configured := profile.IMSRegisterOptions.PAccessNetworkInfo; configured != nil {
|
||||
return ueProvidedPANI(*configured)
|
||||
}
|
||||
|
||||
node := strings.ToLower(strings.TrimSpace(profile.PANINode))
|
||||
if decoded, err := hex.DecodeString(node); err != nil || len(decoded) != 6 {
|
||||
node = strings.TrimPrefix(sessionPAccessNetworkInfo(instanceID), "IEEE-802.11;i-wlan-node-id=")
|
||||
}
|
||||
if node == "" {
|
||||
return ""
|
||||
}
|
||||
value := "IEEE-802.11;i-wlan-node-id=" + node
|
||||
if country := strings.ToUpper(strings.TrimSpace(profile.PANICountry)); country != "" {
|
||||
value += ";country=" + country
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// ueProvidedPANI removes the network-provided marker from a profile override.
|
||||
// RFC 7315 reserves that marker for a trusted proxy; a UE must not assert it.
|
||||
func ueProvidedPANI(value string) string {
|
||||
parts := strings.Split(strings.TrimSpace(value), ";")
|
||||
filtered := parts[:0]
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" || strings.EqualFold(part, "network-provided") {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, part)
|
||||
}
|
||||
return strings.Join(filtered, ";")
|
||||
}
|
||||
|
||||
func (session *Session) exchange(ctx context.Context, request []byte, cseq uint32) (*sipResponse, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -3,6 +3,7 @@ package ims
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -361,6 +362,7 @@ func TestRefreshFailureRevokesRegistrationEvidence(t *testing.T) {
|
||||
|
||||
func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) error {
|
||||
var callID string
|
||||
var pani string
|
||||
for step := 0; step < 4; step++ {
|
||||
packet := make([]byte, 65535)
|
||||
count, remote, err := listener.ReadFromUDP(packet)
|
||||
@@ -377,7 +379,6 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
|
||||
for _, forbidden := range []string{
|
||||
"p-visited-network-id",
|
||||
"p-preferred-identity",
|
||||
"p-access-network-info",
|
||||
} {
|
||||
if headers[forbidden] != "" {
|
||||
return fmt.Errorf(
|
||||
@@ -387,6 +388,15 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
|
||||
)
|
||||
}
|
||||
}
|
||||
currentPANI := headers["p-access-network-info"]
|
||||
if err := validateTestPANI(currentPANI); err != nil {
|
||||
return fmt.Errorf("REGISTER PANI: %w", err)
|
||||
}
|
||||
if step == 0 {
|
||||
pani = currentPANI
|
||||
} else if currentPANI != pani {
|
||||
return fmt.Errorf("REGISTER PANI changed from %q to %q", pani, currentPANI)
|
||||
}
|
||||
if !strings.Contains(headers["allow"], "MESSAGE") ||
|
||||
!strings.Contains(string(packet[:count]), "Accept-Contact: *;+g.3gpp.smsip") {
|
||||
return fmt.Errorf("REGISTER omitted SMS-over-IMS capability: Allow=%q", headers["allow"])
|
||||
@@ -495,24 +505,43 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestOptionalRegisterHeaderRequiresExplicitNonemptyValue(t *testing.T) {
|
||||
explicit := " IEEE-802.11;i-wlan-node-id=aabbccddeeff "
|
||||
empty := " "
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
value *string
|
||||
want string
|
||||
}{
|
||||
{name: "unspecified", value: nil, want: ""},
|
||||
{name: "explicit omission", value: &empty, want: ""},
|
||||
{name: "explicit value", value: &explicit, want: "IEEE-802.11;i-wlan-node-id=aabbccddeeff"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := optionalRegisterHeader(test.value); got != test.want {
|
||||
t.Fatalf("optionalRegisterHeader() = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
func TestSessionPAccessNetworkInfoIsStableAndUEProvided(t *testing.T) {
|
||||
instanceID := "urn:uuid:00000000-0000-4000-8000-000000000001"
|
||||
first := sessionPAccessNetworkInfo(instanceID)
|
||||
second := sessionPAccessNetworkInfo(instanceID)
|
||||
if first != second {
|
||||
t.Fatalf("PANI changed for one SIP instance: %q != %q", first, second)
|
||||
}
|
||||
if err := validateTestPANI(first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ueProvidedPANI(" IEEE-802.11;i-wlan-node-id=aabbccddeeff;network-provided "); got != "IEEE-802.11;i-wlan-node-id=aabbccddeeff" {
|
||||
t.Fatalf("ueProvidedPANI() = %q", got)
|
||||
}
|
||||
if got := ueProvidedPANI("network-provided"); got != "" {
|
||||
t.Fatalf("marker-only PANI = %q, want empty", got)
|
||||
}
|
||||
if got := (&Session{paniResolved: true}).pAccessNetworkInfo(); got != "" {
|
||||
t.Fatalf("explicitly omitted session PANI = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func validateTestPANI(value string) error {
|
||||
const prefix = "IEEE-802.11;i-wlan-node-id="
|
||||
if !strings.HasPrefix(value, prefix) {
|
||||
return fmt.Errorf("value %q does not start with %q", value, prefix)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(value), "network-provided") {
|
||||
return fmt.Errorf("UE PANI incorrectly claims network-provided provenance: %q", value)
|
||||
}
|
||||
node, err := hex.DecodeString(strings.TrimPrefix(value, prefix))
|
||||
if err != nil || len(node) != 6 {
|
||||
return fmt.Errorf("i-wlan-node-id must be 12 hexadecimal digits: %q", value)
|
||||
}
|
||||
if node[0]&0x03 != 0x02 {
|
||||
return fmt.Errorf("i-wlan-node-id must be a locally administered unicast identifier: %q", value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func serveRefreshFailure(listener *net.UDPConn, nonce string) error {
|
||||
|
||||
@@ -56,6 +56,7 @@ type ReceivedSMS struct {
|
||||
RawTPDU string
|
||||
DecodeError string
|
||||
}
|
||||
|
||||
// ReceivedSMSStatus is network delivery evidence for one submitted SMS part.
|
||||
type ReceivedSMSStatus struct {
|
||||
DeviceID string
|
||||
@@ -1165,6 +1166,9 @@ func (session *Session) sendSIPMessageWith(
|
||||
fmt.Sprintf("CSeq: %d MESSAGE", cseq),
|
||||
"P-Preferred-Identity: <"+session.identity.public+">",
|
||||
)
|
||||
if pani := session.pAccessNetworkInfo(); pani != "" {
|
||||
lines = append(lines, "P-Access-Network-Info: "+pani)
|
||||
}
|
||||
if acceptContactTag != "" {
|
||||
lines = append(lines, "Accept-Contact: *;+g.3gpp."+acceptContactTag)
|
||||
}
|
||||
|
||||
@@ -352,6 +352,10 @@ func serveInboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<- s
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
registerPANI := headers["p-access-network-info"]
|
||||
if err := validateTestPANI(registerPANI); err != nil {
|
||||
return fmt.Errorf("initial REGISTER PANI: %w", err)
|
||||
}
|
||||
callID := headers["call-id"]
|
||||
if _, err = listener.WriteToUDP(testResponse(401, "Unauthorized", callID, headers["cseq"], []string{
|
||||
`WWW-Authenticate: Digest realm="ims.mnc001.mcc001.3gppnetwork.org", nonce="` + nonce + `", algorithm=AKAv1-MD5, qop="auth"`,
|
||||
@@ -366,6 +370,9 @@ func serveInboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<- s
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if headers["p-access-network-info"] != registerPANI {
|
||||
return errors.New("authenticated REGISTER changed PANI")
|
||||
}
|
||||
if _, err = listener.WriteToUDP(testResponse(200, "OK", callID, headers["cseq"], []string{
|
||||
"Contact: " + headers["contact"] + ";expires=600",
|
||||
}), remote); err != nil {
|
||||
@@ -433,6 +440,9 @@ func serveInboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<- s
|
||||
len(report.Request.Body) != 2 || report.Request.Body[0] != 0x02 || report.Request.Body[1] != 0x2a {
|
||||
return fmt.Errorf("unexpected delivery report %#v", report.Request)
|
||||
}
|
||||
if report.Request.value("P-Access-Network-Info") != registerPANI {
|
||||
return errors.New("inbound SMS RP-ACK did not reuse REGISTER PANI")
|
||||
}
|
||||
if _, err = listener.WriteToUDP(testResponse(200, "OK", report.Request.value("Call-ID"), report.Request.value("CSeq"), nil), remote); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -449,6 +459,9 @@ func serveInboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<- s
|
||||
if headers["expires"] != "0" {
|
||||
return errors.New("expected deregistration")
|
||||
}
|
||||
if headers["p-access-network-info"] != registerPANI {
|
||||
return errors.New("deregistration changed PANI")
|
||||
}
|
||||
_, err = listener.WriteToUDP(testResponse(200, "OK", callID, headers["cseq"], nil), remote)
|
||||
return err
|
||||
}
|
||||
@@ -463,6 +476,10 @@ func serveOutboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<-
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
registerPANI := headers["p-access-network-info"]
|
||||
if err := validateTestPANI(registerPANI); err != nil {
|
||||
return fmt.Errorf("initial REGISTER PANI: %w", err)
|
||||
}
|
||||
registerCallID := headers["call-id"]
|
||||
if _, err = listener.WriteToUDP(testResponse(401, "Unauthorized", registerCallID, headers["cseq"], []string{
|
||||
`WWW-Authenticate: Digest realm="ims.mnc001.mcc001.3gppnetwork.org", nonce="` + nonce + `", algorithm=AKAv1-MD5, qop="auth"`,
|
||||
@@ -477,6 +494,9 @@ func serveOutboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<-
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if headers["p-access-network-info"] != registerPANI {
|
||||
return errors.New("authenticated REGISTER changed PANI")
|
||||
}
|
||||
if _, err = listener.WriteToUDP(testResponse(200, "OK", registerCallID, headers["cseq"], []string{
|
||||
"Contact: " + headers["contact"] + ";expires=600",
|
||||
}), remote); err != nil {
|
||||
@@ -508,6 +528,9 @@ func serveOutboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<-
|
||||
message.Request.value("Allow") != "MESSAGE" {
|
||||
return fmt.Errorf("unexpected outbound MESSAGE %#v", message.Request)
|
||||
}
|
||||
if message.Request.value("P-Access-Network-Info") != registerPANI {
|
||||
return errors.New("outbound SMS MESSAGE did not reuse REGISTER PANI")
|
||||
}
|
||||
rpdu, err := parseRPDU(message.Request.Body)
|
||||
if err != nil || rpdu.messageType != 0 || len(rpdu.tpdu) != 0 {
|
||||
// parseRPDU intentionally decodes only network-to-MS RP-DATA; inspect
|
||||
@@ -573,6 +596,9 @@ func serveOutboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<-
|
||||
len(statusACK.Request.Body) != 2 || statusACK.Request.Body[0] != 0x02 || statusACK.Request.Body[1] != 0x2b {
|
||||
return fmt.Errorf("unexpected status RP-ACK %#v (%v)", statusACK.Request, err)
|
||||
}
|
||||
if statusACK.Request.value("P-Access-Network-Info") != registerPANI {
|
||||
return errors.New("status-report RP-ACK did not reuse REGISTER PANI")
|
||||
}
|
||||
if _, err = listener.WriteToUDP(testResponse(200, "OK", statusACK.Request.value("Call-ID"), statusACK.Request.value("CSeq"), nil), remote); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -589,6 +615,9 @@ func serveOutboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<-
|
||||
if headers["expires"] != "0" {
|
||||
return errors.New("expected deregistration")
|
||||
}
|
||||
if headers["p-access-network-info"] != registerPANI {
|
||||
return errors.New("deregistration changed PANI")
|
||||
}
|
||||
_, err = listener.WriteToUDP(testResponse(200, "OK", registerCallID, headers["cseq"], nil), remote)
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user